Compare commits

...
221 Commits
Author SHA1 Message Date
Henrique Dias fb02929c3a fix: authorize the destination collection a copy or move replaces
A COPY or MOVE onto an existing destination replaces it, which destroys
everything the destination collection held. Only the source subtree was
authorized per descendant, and the destination itself was authorized as an
update, so a rule restricting a subtree held against DELETE but not against
an overwrite of the collection above it. Removing a collection is now
authorized the way DELETE on it would be, on the collection and on every
path beneath it. Writing over a file stays an update.
2026-09-20 16:25:19 +02:00
renovate[bot] 802bfa1eb3 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 1f7c531 (#363) 2026-09-20 12:01:10 +02:00
renovate[bot] 1c0110bdb5 chore(deps): update all non-major dependencies (#364) 2026-09-20 11:59:31 +02:00
renovate[bot] be20c9cc71 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 86efde5 (#360) 2026-09-08 13:59:45 +02:00
Henrique Dias 64c669d8a7 docs: improved reverse proxy docs 2026-09-08 13:53:49 +02:00
renovate[bot] 6775297f4f chore(deps): update module golang.org/x/crypto to v0.56.0 (#361) 2026-09-07 16:09:27 +02:00
Henrique Dias 04c863aefc test: compare flipCase results with OS-native separators
The expectations were written with forward slashes, but flipCase builds a
path to stat and so separates it the way the running system does.
2026-09-04 14:36:46 +02:00
Henrique Dias 3ded167a52 fix: authorize every path an operation touches
Recursive COPY, MOVE, DELETE and PROPFIND were checked only against the
requested path, reaching descendants their rules deny. A broad rule shadowed
a narrower one naming a collection. LOCK was allowed on any permission, so a
read-only user could create files and block writers. Rules compared case
where the backing file system does not.
2026-09-04 14:32:32 +02:00
Henrique Dias 081d20405f docs: add CLAUDE.md 2026-09-04 12:53:44 +02:00
Mao Mao 4733aa03c4 feat: support CORS Private Network Access (#350) 2026-08-28 15:27:04 +02:00
renovate[bot] 6dc4d8de20 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to afebf4c (#351) 2026-08-28 15:24:17 +02:00
renovate[bot] d0ad4623a9 chore(deps): update all non-major dependencies (#355) 2026-08-28 15:24:08 +02:00
renovate[bot] 203d2f72bf chore(deps): update all non-major dependencies (#352) 2026-08-19 09:23:05 +02:00
Henrique Dias f869dd6276 Merge commit from fork
* fix: resolve dot segments before checking path rules (GHSA-chxv-mvjv-f92j)

* fix: match trailing-slash path rules against the bare collection

* fix: match destination rules against the URL path when no prefix is set

* fix: restrict collections named by a rule without granting access to them

* docs: cleanup
2026-08-05 10:18:13 +02:00
Henrique Dias c04649bf40 docs: add security policy 2026-08-02 07:28:17 +02:00
snowy_smile 390fe21ed9 fix: use slash-separated lock paths on Windows (#344) 2026-07-25 07:15:27 +02:00
renovate[bot] 44e5e02dd3 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to d701c51 (#349) 2026-07-25 07:14:36 +02:00
renovate[bot] d59dd02f96 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to ff03daf (#347) 2026-07-19 09:19:16 +02:00
renovate[bot] 10183d09bc chore(deps): update actions/setup-go action to v7 (#348) 2026-07-19 09:16:47 +02:00
Henrique Dias 1dceeb296a docs: remove deprecated go report card 2026-07-12 09:52:30 +02:00
renovate[bot] 2bf7130f56 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 243e02a (#345) 2026-07-12 09:51:24 +02:00
Higanoneko 7ea4cec229 feat: add multi directories support (#335) 2026-07-12 09:51:10 +02:00
renovate[bot] de2ac9d327 chore(deps): update all non-major dependencies (#346) 2026-07-11 09:37:17 +02:00
renovate[bot] ca617862a5 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to cdce021 (#341) 2026-07-09 14:24:45 +02:00
Janlay WuandHenrique Dias 3ddd9182f9 feat: add partial update support (#331)
Co-authored-by: Henrique Dias <[email protected]>
2026-06-27 10:00:57 +02:00
renovate[bot] 71fce1a29e chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 5b7f841 (#340) 2026-06-27 09:41:45 +02:00
renovate[bot] db385c76b2 chore(deps): update all digest updates (#338) 2026-06-24 08:51:24 +02:00
renovate[bot] f9c771fdad chore(deps): update actions/checkout action to v7 (#339) 2026-06-24 08:51:15 +02:00
renovate[bot] 7cc3f8b4ae chore(deps): update all non-major dependencies (#336) 2026-06-13 07:11:02 +02:00
renovate[bot] 36063ce391 chore(deps): update all digest updates to 5f2de1a (#334) 2026-06-11 10:57:08 +02:00
renovate[bot] 6c3258a469 chore(deps): update all digest updates to e2ffffe (#332) 2026-06-03 14:15:33 +02:00
renovate[bot] 3ef6dd5ba5 chore(deps): update all non-major dependencies (#333) 2026-06-03 13:55:28 +02:00
renovate[bot] d57f57f3a0 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 44decbf (#328) 2026-05-14 20:21:59 +02:00
renovate[bot] c3cfaf18f5 chore(deps): update all non-major dependencies (#329) 2026-05-14 20:10:16 +02:00
Bingshen Wang 0128eed63d docs: fix CORS allowed_hosts example 2026-05-14 20:09:47 +02:00
renovate[bot] f051809a5f chore(deps): update golang.org/x/crypto/x509roots/fallback digest to fd0b90d 2026-05-05 07:42:30 +02:00
renovate[bot] f13c884b75 chore(deps): update module go.uber.org/zap to v1.28.0 2026-05-05 07:42:16 +02:00
renovate[bot] cef2e3b673 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to b9e5359 2026-04-28 07:20:05 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 4fe4703e02 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to a8e9237 (#321)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-18 07:53:04 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 882c2200eb chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 03ca0dc (#317)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 07:42:03 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 4f4045dbca chore(deps): update all non-major dependencies (#318)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 07:40:06 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 20606cd795 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 8400f4a (#314)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 08:47:47 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> c9ca42d89a chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 81c6cb3 (#313)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-21 07:12:15 +01:00
Henrique Dias 8217bc0816 refactor: use http.Server 2026-03-14 07:46:25 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 98f8f93ebb chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 982eaa6 (#311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-14 07:42:38 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 66049d9c08 chore(deps): update all non-major dependencies (#312)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-14 07:40:45 +01:00
Henrique Dias 0406449b05 chore: add additional test 2026-03-09 11:25:59 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> d7745cff34 chore(deps): update docker/setup-qemu-action action to v4 (#309)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 07:40:17 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> d8558a77ff chore(deps): update docker/build-push-action action to v7 (#305)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 07:39:47 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> ab5cf175ad chore(deps): update docker/login-action action to v4 (#306)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 07:39:41 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> f225b0418f chore(deps): update docker/metadata-action action to v6 (#307)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 07:39:35 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> d9600ee82d chore(deps): update docker/setup-buildx-action action to v4 (#308)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 07:39:27 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 6359115c2d chore(deps): update module golang.org/x/net to v0.51.0 (#304)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-28 11:09:54 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8574b461a9 chore(deps): update goreleaser/goreleaser-action action to v7 (#302)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-21 18:13:54 +01:00
Henrique Dias 1196ad506d fix: revert Dockerfile changes 2026-02-16 17:13:56 +01:00
Henrique Dias 4ac9a1041c fix: remove broken targets 2026-02-15 14:16:59 +01:00
Henrique Dias 27aad9d403 fix: test 2026-02-15 14:05:07 +01:00
Henrique Dias e06f26b095 feat: update default CORS settings 2026-02-15 14:02:31 +01:00
Legendary4226 04070a8ae8 docs: add compose.yml and clarifications 2026-02-15 13:57:49 +01:00
renovate[bot] bd0667aac1 chore(deps): update all non-major dependencies 2026-02-15 13:57:03 +01:00
renovate[bot] b620cd5c88 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to a408498 2026-02-15 13:54:43 +01:00
renovate[bot] c4c58fe420 chore(deps): update module github.com/coreos/go-systemd/v22 to v22.7.0 2026-02-01 13:41:40 +01:00
renovate[bot] 0ff71ca067 chore(deps): update module github.com/studio-b12/gowebdav to v0.12.0 2026-01-24 14:31:55 +01:00
renovate[bot] efdaa784b9 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 7d0074c 2026-01-18 08:34:19 +01:00
renovate[bot] 7662d629e3 chore(deps): update all non-major dependencies 2026-01-17 22:45:01 +01:00
renovate[bot] 010ca576fb chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 7dacc38 2025-12-13 09:10:00 +01:00
renovate[bot] 7b8aa1c422 chore(deps): update all non-major dependencies 2025-12-13 09:05:43 +01:00
Henrique Dias 2f98af7d1e ci: run renovate on weekends 2025-12-05 15:10:32 +01:00
renovate[bot] 50e1828b60 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 3a1c6b4 2025-12-05 14:34:40 +01:00
renovate[bot] e78d59e374 chore(deps): update module github.com/spf13/cobra to v1.10.2 2025-12-05 14:31:30 +01:00
renovate[bot] de0a102ed8 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to f4602e4 2025-12-03 08:46:31 +01:00
Henrique Dias bdfa98d00d feat: use golang.org/x/crypto/x509roots/fallback 2025-11-20 19:12:12 +01:00
renovate[bot] f0e7e23ea8 chore(deps): update actions/checkout action to v6 2025-11-20 18:55:14 +01:00
renovate[bot] 065991a195 chore(deps): update all non-major dependencies 2025-11-20 08:09:56 +01:00
Henrique Dias bb85514c8f chore: use 'chore' in all commits from renovate 2025-11-13 14:11:51 +01:00
renovate[bot] c0af549a1f fix(deps): update all non-major dependencies 2025-11-12 07:19:53 +01:00
Emmanuel Ferdman 685d89162a chore: update GoReleaser configurations
Signed-off-by: Emmanuel Ferdman <[email protected]>
2025-11-10 17:17:48 +01:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> a5496c9516 chore(deps): update golangci/golangci-lint-action action to v9 (#276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 07:36:01 +01:00
Michael Bruns 24abd52e23 feat: enhanced logging 2025-10-30 07:55:50 +01:00
Henrique Dias 1af4c723f4 ci: fix releaser go version 2025-10-17 17:21:51 +02:00
Henrique Dias 2406836223 fix: additive permissions 2025-10-17 17:12:11 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 7ca822bcaf fix(deps): update all non-major dependencies (#271)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-12 15:46:19 +02:00
Chris a98d0c2616 docs: add Caddy configuration example 2025-09-11 09:33:54 +02:00
renovate[bot] 98e92d0c13 fix(deps): update module golang.org/x/net to v0.44.0 2025-09-10 14:27:35 +02:00
renovate[bot] 53416e57da fix(deps): update all non-major dependencies 2025-09-09 08:16:16 +02:00
renovate[bot] ff6637f396 chore(deps): update actions/setup-go action to v6 2025-09-06 08:32:02 +02:00
renovate[bot] 15fbcd391b fix(deps): update module github.com/studio-b12/gowebdav to v0.11.0 2025-09-06 08:31:20 +02:00
Henrique Dias 70b3630d08 chore: update dependencies 2025-09-03 07:45:22 +02:00
renovate[bot] aa014b092b fix(deps): update module github.com/spf13/pflag to v1.0.8 2025-09-03 07:43:32 +02:00
renovate[bot] b4553710eb fix(deps): update module github.com/stretchr/testify to v1.11.1 2025-08-27 20:43:15 +02:00
renovate[bot] e07e55d97a fix(deps): update module github.com/stretchr/testify to v1.11.0 2025-08-25 08:36:54 +02:00
renovate[bot] cfee0f9f0b fix(deps): update module github.com/coreos/go-systemd/v22 to v22.6.0 2025-08-21 09:46:43 +02:00
Henrique Dias 79eb02aba4 chore: temporarily disable risc docker 2025-08-15 09:35:51 +02:00
Henrique Dias 7bbe36e4c1 chore: update alpine version 2025-08-15 09:29:28 +02:00
Henrique Dias 520b7df04d chore: update dependencies 2025-08-15 09:19:31 +02:00
renovate[bot] 8f3a3298a8 chore(deps): update dependency go to 1.25.x 2025-08-15 09:15:27 +02:00
renovate[bot] 1753aa2945 chore(deps): update actions/checkout action to v5 2025-08-11 16:28:32 +02:00
renovate[bot] 4645f6f46f fix(deps): update all non-major dependencies 2025-08-08 07:36:35 +02:00
renovate[bot] e998e2a838 fix(deps): update module github.com/spf13/pflag to v1.0.7 2025-07-17 06:49:00 +02:00
renovate[bot] 3b3dedc4eb fix(deps): update module github.com/go-viper/mapstructure/v2 to v2.4.0 2025-07-15 19:23:12 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 8706802dac fix(deps): update all non-major dependencies (#248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 07:47:38 +02:00
Henrique Dias 5676a1c382 chore: update renovate.json 2025-06-21 09:54:30 +02:00
Henrique Dias c938367dc0 chore: add go.mod tidy to renovate.json 2025-06-20 07:56:44 +02:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Henrique Dias
758410ba64 fix(deps): update all dependencies
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Henrique Dias <[email protected]>
2025-06-20 07:56:09 +02:00
Henrique Dias 1bbf52e475 chore: update renovate.json 2025-06-20 07:47:58 +02:00
Henrique Dias 2c2a325215 chore: update renovate.json 2025-06-19 23:22:56 +02:00
renovate[bot] f7e006b449 fix(deps): update module github.com/go-viper/mapstructure/v2 to v2.3.0 2025-06-19 23:18:12 +02:00
renovate[bot] 49f61eab79 fix(deps): update module github.com/studio-b12/gowebdav to v0.10.0 2025-06-19 23:17:57 +02:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 5d1a68c4f2 chore: add renovate.json (#235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 22:56:45 +02:00
Henrique Dias 04cee682fb docs: update Homebrew title 2025-05-25 07:45:53 +02:00
Henrique Dias 19d128cfbd docs: add brew 2025-05-25 07:44:09 +02:00
酸柠檬猹/SourLemonJuice 118a0dda88 feat: add docker build target of linux/riscv64 to workflow 2025-05-21 15:42:50 +02:00
Henrique Dias aad8e563a7 feat: add executable's directory as config path 2025-05-20 18:09:52 +02:00
Henrique Dias 8446f9bdae docs: add nginx note about copy/move 2025-05-20 18:06:26 +02:00
Henrique Dias 5226853fca fix: lock files across different users
Fixes #66.
2025-05-17 10:53:07 +02:00
Henrique Dias e350dc989a docs(readme): make it clear that path.rule is relative to user's directory 2025-05-17 10:47:57 +02:00
Henrique Dias 65de8e8578 chore: update dependencies 2025-05-17 09:14:37 +02:00
Henrique Dias f0b5a39d80 ci: go 1.24 2025-03-18 07:53:28 +01:00
Henrique Dias dbd708a76e chore: update dependencies 2025-03-18 07:53:28 +01:00
Henrique Dias f61b7cab3b docs: cleanup comments 2025-02-12 07:42:15 +01:00
Henrique Dias 3cfaa0da3e fix: return correct amount of written bytes 2025-02-12 07:41:21 +01:00
networkException f7b78cd834 fix: support HEAD method on collections 2025-02-12 07:41:09 +01:00
Henrique Dias 4b4e555ed5 fix: bring back the prefix test 2025-01-16 07:10:07 +01:00
Henrique Dias 6aeb3f8a30 fix: refactoring previous fix in attempt to address ongoing issues
See #211 and #215
2025-01-15 19:35:03 +01:00
Henrique Dias 59c5f1343f Revert "fix: rename/copy when using prefix"
This reverts commit 617496c019.
2025-01-15 19:18:40 +01:00
Henrique Dias 617496c019 fix: rename/copy when using prefix 2025-01-04 18:45:20 +01:00
Andrey Denisov 3790d7de08 docs(readme): improve fail2ban description 2024-12-30 20:10:07 +01:00
Henrique Dias eaf42b03e9 feat: add bcrypt command 2024-12-22 10:28:49 +01:00
Henrique Dias dce6010b61 chore: update dependencies 2024-12-21 08:04:50 +01:00
Henrique Dias 88863d7242 ci: update golangci-lint 2024-12-15 18:30:50 +01:00
Henrique Dias 2c96db92aa chore: update dependencies 2024-12-15 18:30:28 +01:00
networkException 79bc17afab feat: add support for systemd listener activation sockets
To support starting a network service on demand and to
support a "least privilege-approach" with regards to the
permission a network service process needs to have, systemd
supports opening a network socket on behalf of the service
and passing it as an open file descriptor.

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

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

See sd_listen_fds(3)
2024-12-11 17:29:03 +01:00
Henrique Dias 51b101d3d8 feat: rules behavior 2024-12-07 12:07:12 +01:00
Henrique Dias ca7f3374d5 chore: update dependencies 2024-11-28 16:59:04 +01:00
Henrique Dias 64bbdc7b15 fix: error if rule has no regex or path 2024-11-28 16:57:10 +01:00
Steven Vandevelde d418bd2661 fix: Pass ExposedHeaders to cors.New 2024-11-19 17:52:14 +01:00
Henrique Dias d500716f29 fix: spoofing of X-Forwarded-For 2024-10-21 08:15:19 +02:00
Henrique Dias 8c49af0b68 fix: environment parsing for username 2024-10-20 09:09:05 +02:00
Henrique Dias a5777e18ee chore: update dependencies 2024-10-12 14:41:05 +02:00
Henrique Dias 49a6e935c3 docs(readme): make fail2ban config part of the examples 2024-10-12 14:40:29 +02:00
炯轩 a698e31cb4 chore: removed the the login attempt log commented line to makes the code cleaner and more focused 2024-10-12 14:38:42 +02:00
Jiongxuan Zhang 74b514c877 feat(authentication): enhance login failure logging and reduce log volume
- Added logging for invalid username attempts to provide more detailed failure reasons.
- Removed "login attempt" log entries to reduce log volume and focus on final verification results.
- Retained logging for invalid password and successful user authorization for clarity.
2024-10-12 14:38:42 +02:00
Jiongxuan Zhang ca0bdb1cfa docs: add Fail2Ban configuration guide to README
- Added a section in README.md explaining how to configure Fail2Ban for WebDAV security.
- Included examples for filter and jail configuration.
- Provided instructions on setting up and testing Fail2Ban to block IPs after failed login attempts.
2024-10-12 14:38:42 +02:00
Jiongxuan Zhang a056e1ba18 feat(authentication): improve IP logging by extracting real client IP from X-Forwarded-For header
- Added getRealRemoteIP function to retrieve the real client IP address when behind a reverse proxy.
- Updated authentication logging to use the extracted IP instead of r.RemoteAddr.
- Ensured compatibility for both proxy and non-proxy setups, falling back to r.RemoteAddr when X-Forwarded-For is not present.
2024-10-12 14:38:42 +02:00
Keith Gaughan 189af88bc8 chore: omit debug information release builds (#185) 2024-09-08 21:34:26 +02:00
Henrique Dias 4e87e6a613 chore: disable CGO (#184) 2024-08-25 15:06:46 +02:00
Henrique Dias 63449f1636 fix: check permissions at copy/move source and destination (#181) 2024-08-21 18:15:32 +02:00
Henrique Dias 4ad26dad35 ci: use go 1.23 2024-08-19 19:32:46 +02:00
Henrique Dias 623bbc9a70 chore: update dependencies 2024-08-19 19:32:46 +02:00
Henrique Dias feeb33d249 docs: add note about noSniff 2024-08-01 21:53:58 +02:00
Henrique Dias d3bee98000 feat: allow disabling password check for delegated authentication 2024-08-01 21:53:58 +02:00
Henrique Dias 373b2ec931 docs: fix nginx configuration
Closes #132
2024-08-01 10:39:21 +02:00
Henrique Dias 000f404f7a docs: remove dev version note 2024-07-31 10:07:23 +01:00
Henrique Dias e4a8622c1e docs: remove outdated SECURITY.md 2024-07-31 10:07:01 +01:00
Henrique Dias b5a3d07f5c feat!: fine-grained permissions 2024-07-31 11:06:34 +02:00
Henrique Dias f4de82cfd1 feat: add test for server listing characters 2024-07-30 15:01:17 +02:00
Henrique Dias ebcf500d5e docs: cleanup readme 2024-07-29 09:13:46 +01:00
Henrique Dias d7faa1f887 feat!: further log customizations 2024-07-29 10:11:02 +02:00
Henrique Dias d5e5052f63 feat!: rename 'scope' to 'directory'
Directory makes it more clear of what it is. In addition, this will make it easier when
allowing for multiple directories in the future, since we can just name it 'directories', which is more clear than 'scopes'.
2024-07-29 10:11:02 +02:00
Henrique Dias a255fb51e2 feat!: remove Auth option 2024-07-29 10:11:02 +02:00
Henrique Dias ed23ca1820 feat!: change default port and scope
BREAKING CHANGE: the default port is no longer random, but 6065. The default scope is now the current directory instead of the root directory.
2024-07-29 10:11:02 +02:00
Henrique Dias e7e9c3176d feat!: simplified rule with regex instead of boolean
BREAKING CHANGE: the "regex" field in the rule is now a regular expression instead of a boolean.
2024-07-29 10:11:02 +02:00
Henrique Dias d3732322bc chore: bump version to v5 2024-07-29 10:10:19 +02:00
Henrique Dias f708664906 feat: permissions, auth, rules basic tests 2024-07-26 17:18:46 +02:00
Henrique Dias 814462bed1 fix: environment variable parsing
This is more of a workaround than the correct solution. It only fixes top-level ENV variables parsing.
2024-07-25 22:46:34 +02:00
Henrique Dias f6a0707fe6 refactor: shorten response writer code 2024-07-22 22:28:56 +02:00
Henrique Dias 947b163ea7 fix: rules parsing 2024-07-22 22:25:50 +02:00
Henrique Dias 732cf5eff5 docs: fix readme highlighting 2024-07-22 19:22:25 +02:00
Henrique Dias 1e87b21bb1 docs: improve configuration section 2024-07-22 18:55:04 +02:00
Henrique Dias 6166061f20 docs: install, docker, systemd instructions 2024-07-22 18:55:04 +02:00
Henrique Dias 4f8eab48ab fix: config parsing keys 2024-07-22 18:36:58 +02:00
Henrique Dias 7542860a47 fix: panic when getting requests 2024-07-22 18:32:57 +02:00
Henrique Dias 3688420246 feat: centrally defined defaults 2024-07-22 17:52:56 +02:00
Henrique Dias 47e3f6de6f fix: remove 'v' from version name 2024-07-21 21:43:53 +02:00
Henrique Dias 356edb8b93 feat: add tests for json and toml config 2024-07-21 21:41:16 +02:00
Henrique Dias b16c041d0c fix: add 'v' prefix to version 2024-07-21 21:31:52 +02:00
Henrique Dias dc45f32af8 fix: dockerfile build version 2024-07-21 21:25:49 +02:00
Henrique Dias d1691e1bd1 fix: docker workflow tags 2024-07-21 21:03:23 +02:00
Henrique Dias 5f685dbe98 ci: new goreleaser workflow (#157) 2024-07-21 21:01:03 +02:00
Henrique Dias 85219df921 ci: use cache for docker builds 2024-07-21 20:53:06 +02:00
Henrique Dias c125bedae1 refactor: code cleanup, stricter config validation (#155) 2024-07-21 20:52:50 +02:00
Henrique Dias 46d54e4465 ci: fix docker image attestation 2024-07-21 20:45:14 +02:00
Henrique Dias 90c031846d ci: new docker release workflows 2024-07-21 20:32:59 +02:00
Henrique Dias 968f2e147a chore: update dependencies 2024-07-21 19:47:04 +02:00
Henrique Dias 6f4be12e8c ci: updated build and lint workflows (#152) 2024-07-21 19:43:30 +02:00
zmaplex 9433dbd452 fix: correctly close the listener 2024-07-18 21:24:08 +02:00
Henrique Dias f0ca85e570 docs: add disclaimer 2022-09-26 11:58:28 +02:00
Ethan Davis 436a3b05a1 fix: 'modify:false' is respected 2022-01-18 15:17:20 +01:00
Rui Sun 1a610b17ba feat: add armv7 support 2022-01-18 15:17:01 +01:00
Mohammed Al Sahaf 32613f76cc ci: update Go version to go1.17 2021-10-19 20:52:48 +03:00
Mohammed Al Sahaf 8a8650d9b2 feat: introduce structured, field logging (#87)
* feat: introduce structured, field logging

The logging is currently minimal and only for initial message of listening and user login attempts

* chore: major version upgarde

there was a breaking API change in 8cd6d0a585 for which the git tag was v4.0.0 but the Go SIV was missed. This commit updates the SIV and retracts the defective version of v4.1.0.
2021-10-19 17:45:54 +00:00
Ziding ZhangandMohammed Al Sahaf 5701cbb5b8 Create SECURITY.md (#81)
* Create SECURITY.md

simple instruction for security researchers

* Update SECURITY.md

Co-authored-by: Mohammed Al Sahaf <[email protected]>
2021-07-27 12:01:24 +03:00
9a4b378b32 feat: add source request log (#71)
Co-authored-by: shenyuning <[email protected]>
Co-authored-by: sxueck <[email protected]>
2021-07-01 15:15:33 +02:00
Justyn 099479a894 feat: add a config option to bypass mime content type sniffing (#60) 2021-01-14 14:47:33 +00:00
mik2k2 8cd6d0a585 feat: per path modify permissions (#57) 2021-01-14 14:43:52 +00:00
mik2k2 fca4e54839 feat: add support for unix sockets (#58) 2020-12-17 22:14:03 +00:00
Mohammed Al Sahaf da6dd253d5 chore: use fetch-depth to fetch complete history
Fixes the issue of publishing all commit since inception (see: goreleaser/goreleaser#990)
2020-10-10 03:28:00 +03:00
Mohammed Al Sahaf c1a919681b chore: publish releases immediately (not draft) 2020-10-10 03:09:23 +03:00
Mohammed Al Sahaf 5734366c54 Dockerfile: revert 05e995b
The commit 05e995b doesn't work well with goreleaser (see: goreleaser/goreleaser#694), which broke the Docker image build. Reverting the commit should restore sanity in the goreleaser flow.
2020-09-19 01:11:48 +03:00
Mohammed Al Sahaf aab63c0ccc chore: housekeeping 2020-09-19 01:11:23 +03:00
Mohammed Al Sahaf f212531d75 chore: Switch to Github Actions (#53) 2020-09-18 21:27:22 +01:00
Henrique Dias 2a39779cff Update config.yml 2020-09-18 07:06:16 +01:00
Mohammed Al Sahaf 34f2336d91 chore: fix circleci release config (#52) 2020-09-18 07:02:51 +01:00
Henrique Dias 6c5420aa5a chore: install go 2020-08-26 08:44:14 +02:00
Henrique Dias 30c210d097 chore: use specific relese image 2020-08-26 08:09:44 +02:00
Henrique Dias f408f7aa6f chore: remove setup_remote_docker 2020-08-26 08:02:41 +02:00
Henrique Dias e9cd29578e use machine build on release 2020-08-26 07:58:11 +02:00
Henrique Dias e0020e8110 chore: update go version and golangci
License: MIT
Signed-off-by: Henrique Dias <[email protected]>
2020-08-25 08:24:31 +02:00
pataquets 2078312ec8 Readme.md: Add Docker pulls badge. 2020-08-03 07:53:31 +01:00
pataquets 05e995b11b Dockerfile: make image build from local source code instead of copying outside build. 2020-08-02 07:55:52 +01:00
Shun Zi c4d4734095 fix: GET directory fails when non-root prefix configured (#40) 2020-06-27 08:45:33 +01:00
Mohammed Al Sahaf cf267c1006 chore: build the binary statically to fix the docker image (#44) 2020-06-27 08:44:29 +01:00
Henrique Dias 72d8e39927 fix(BREAKING): rename webdav/ --> lib/ (#36)
License: MIT
Signed-off-by: Henrique Dias <[email protected]>
2019-11-10 11:30:45 +00:00
Henrique Dias 3ef86e8a7f chore: update dependencies (#35)
License: MIT
Signed-off-by: Henrique Dias <[email protected]>
2019-11-10 11:24:55 +00:00
Shun Zi 2971f7ed3d feat: add URL prefix(subpath) support (#31)
To adapt to situations such as working behind a reverse proxy dispatching query to different services by URL path prefix
2019-11-08 13:01:42 +00:00
Henrique Dias ab0334f036 chore: add mips(64)(le)
License: MIT
Signed-off-by: Henrique Dias <[email protected]>
2019-07-14 09:57:31 +01:00
Henrique Dias 931f125224 chore: go mod tidy
License: MIT
Signed-off-by: Henrique Dias <[email protected]>
2019-06-11 13:12:22 +01:00
Henrique Dias cd472b26be chore: bump to v2.0.0
License: MIT
Signed-off-by: Henrique Dias <[email protected]>
2019-06-11 13:09:51 +01:00
Steven Vandevelde 7358553e69 feat(BREAKING): extend cors functionality (#25) 2019-06-11 13:02:10 +01:00
Henrique Dias 764a69cd33 fix: numeric passwords (#24)
License: MIT
Signed-off-by: Henrique Dias <[email protected]>
2019-06-09 13:51:53 +01:00
Henrique Dias d266f1150e fix: auth enabled by default
License: MIT
Signed-off-by: Henrique Dias <[email protected]>
2019-06-09 13:47:28 +01:00
42 changed files with 6057 additions and 707 deletions
-45
View File
@@ -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: /.*/
+95
View File
@@ -0,0 +1,95 @@
# CLAUDE.md
Guidance for Claude when working in this repository (`hacdias/webdav`).
## Handling security advisories
Advisory state lives on GitHub and is driven with the `gh` CLI: `triage → draft → published`, plus `closed`. Reports are routed as per [SECURITY.md](../SECURITY.md); only `5.x` is supported.
### 1. Fetch
```bash
# List by state (also: published, draft, closed)
gh api '/repos/hacdias/webdav/security-advisories?state=triage&per_page=100' \
--jq '.[] | {ghsa_id, severity, summary, state}'
# Full report for one advisory
gh api /repos/hacdias/webdav/security-advisories/GHSA-xxxx-xxxx-xxxx \
--jq '.summary, "---", .description'
```
Always pull the published and remaining triage sets too, to dedup against.
### 2. Verify — do NOT trust the report text
Read the source at HEAD and reproduce the claim; a failing `makeTestServer` case is better evidence than reading the matcher. Reach one verdict per advisory:
- **CONFIRMED** — defect exists at HEAD. Quote the exact `file:line`.
- **FIXED** — already patched; find the fix commit and the release carrying it.
- **FALSE / NOT APPLICABLE** — claim is wrong, or targets a different project.
- **NOT EXPLOITABLE** — pattern exists but no code path reaches the precondition.
- **DUPLICATE** — of a published advisory, or of another triage advisory.
Common traps:
- **"Incomplete fix of a prior advisory."** Read the original fix commit and confirm the specific sibling path is still unguarded. `GHSA-chxv-mvjv-f92j` already cleans paths in `newRequest` and matches trailing-slash rules against the bare collection.
- **Wrong project.** Confirm the cited files, symbols, and options exist here — reports sometimes describe a fork or another WebDAV server.
- **Containment vs authorization.** `golang.org/x/net/webdav` applies `slashClean` and keeps requests inside the served root, so "traversal out of `directory`" is usually not the defect. The real class is the authorization layer disagreeing with the filesystem layer about which file a request names.
- **No `users:` means no authentication, by design** — it warns at startup. Not a vulnerability, but it changes the privilege precondition.
- **Overlapping reports.** Several triage advisories may share one root cause: consolidate into one, close the rest as duplicates.
Record per advisory: verdict, `file:line` evidence, preconditions (default config? needs `directories:`? platform-specific? auth required?), disposition.
### 3. Severity
Set a CVSS v3.1 vector — GitHub derives the score and severity from it, overriding the plain `severity` field. Encode the real preconditions so the band is defensible: a required configuration or platform (case-insensitive filesystem, `directories:`) is **AC:H**; needing an account is **PR:L**. Rate the base case as a config with a `users:` block, and note the unauthenticated vector in the body when it is materially worse. Don't let an incomplete-fix follow-up outrank its parent.
```bash
gh api -X PATCH /repos/hacdias/webdav/security-advisories/GHSA-xxxx-xxxx-xxxx \
-f cvss_vector_string='CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N' \
--jq '{ghsa_id, severity, score: .cvss.score, vector: .cvss.vector_string}'
```
### 4. Rewrite the title and body
The title is `summary`: concise and sentence-case, stating the vulnerability class then the mechanism, e.g. `Authorization bypass: path rules can be evaded with dot segments or a bare collection name`.
Rewrite `description` into the sections below, reusing the reporter's own wording where it is accurate. Drop the greeting and anything step 2 disproved. Use `###` headings, keep this order, omit what doesn't apply. Keep the maintainer's voice — first person belongs only in quoted PoC steps.
| Section | Contents |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `Summary` | The defect and its root cause, naming the file and function, quoting the pre-fix code. |
| `Impact` | Who can exploit it and what they get. State what is *not* affected. |
| `Proof of concept` | Config and steps trimmed to the essentials, with observed results. |
| `Patches` | `Fixed in **vX.Y.Z**. Upgrade to that version or later.` plus what the fix does and why it sits where it does. |
| `Workarounds` | What the operator can do themselves. `None.` if nothing helped, saying why. |
| `Out of scope` | What the report claimed that is deliberately not treated as a vulnerability, and why. |
| `References` | Related issues, commits, published advisories. |
Send it as a file so the Markdown survives shell quoting:
```bash
jq -Rs '{description: .}' desc.md \
| gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx --input -
```
### 5. Affected versions
The package is always `{ecosystem: "go", name: "github.com/hacdias/webdav/v5"}`. `vulnerable_version_range` ends at the last release before the fix; add a lower bound when the defect was introduced in a known version, confirming with `git log -S` and `git tag --contains`. `patched_versions` is the release carrying the fix.
```bash
printf '%s' '{"vulnerabilities":[{"package":{"ecosystem":"go","name":"github.com/hacdias/webdav/v5"},"vulnerable_version_range":">= 5.10.0, <= 5.14.1","patched_versions":"5.14.2","vulnerable_functions":[]}]}' \
| gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx --input -
```
### 6. Move state
```bash
gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx -f state=draft # ready to publish
gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx -f state=closed # duplicate / N-A / not-exploitable
```
- **CONFIRMED** → fix, release, set `patched_versions` → draft, then publish once the release is out.
- **DUPLICATE / NOT APPLICABLE / NOT EXPLOITABLE** → closed.
The REST API cannot post advisory comments. Replies to reporters must be posted manually in the UI — draft the text for the maintainer.
+21
View 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@v7
- uses: actions/setup-go@v7
with:
go-version: "1.27.x"
- run: go build .
env:
CGO_ENABLED: '0'
+70
View 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@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: |
hacdias/webdav
ghcr.io/hacdias/webdav
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+21
View 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@v7
- uses: actions/setup-go@v7
with:
go-version: "1.27.x"
- uses: golangci/golangci-lint-action@v9
with:
version: "latest"
+27
View File
@@ -0,0 +1,27 @@
name: Releaser
on:
push:
tags:
- '*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v7
with:
go-version: "1.27.x"
- uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+30
View File
@@ -0,0 +1,30 @@
name: Test
on:
push:
tags:
- v*
branches:
- main
pull_request:
jobs:
test:
name: test (${{ matrix.os }})
strategy:
matrix:
os:
- ubuntu-latest
- windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: "1.27.x"
- name: Run test with race detector and coverage
if: runner.os != 'Windows'
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
- name: Run test with coverage
if: runner.os == 'Windows'
run: go test "-coverprofile=coverage.txt" -covermode=atomic ./...
+2
View File
@@ -0,0 +1,2 @@
dist/
webdav
+51 -44
View File
@@ -1,54 +1,61 @@
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
# Broken as of Go 1.24, deprecated as of Go 1.26
- goos: freebsd
goarch: arm
- goos: windows
goarch: arm
archives:
-
name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
format: tar.gz
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
formats:
- tar.gz
format_overrides:
- goos: windows
format: zip
formats:
- zip
dockers:
-
goos: linux
goarch: amd64
goarm: ''
image_templates:
- "hacdias/webdav:latest"
- "hacdias/webdav:{{ .Tag }}"
- "hacdias/webdav:v{{ .Major }}.{{ .Minor }}"
- "hacdias/webdav:v{{ .Major }}"
release:
github:
owner: hacdias
name: webdav
draft: false
prerelease: auto
+15 -6
View File
@@ -1,11 +1,20 @@
FROM alpine:latest as certs
RUN apk --update add ca-certificates
FROM golang:1.26-alpine3.22 AS build
ARG VERSION="untracked"
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 /webdav/main /bin/webdav
COPY webdav /webdav
EXPOSE 6065
ENTRYPOINT [ "/webdav" ]
ENTRYPOINT [ "webdav" ]
+391 -21
View File
@@ -1,59 +1,429 @@
# webdav
[![Build](https://img.shields.io/circleci/project/github/hacdias/webdav/master.svg?style=flat-square)](https://circleci.com/gh/hacdias/webdav)
[![Go Report Card](https://goreportcard.com/badge/github.com/hacdias/webdav?style=flat-square)](https://goreportcard.com/report/hacdias/webdav)
[![Version](https://img.shields.io/github/release/hacdias/webdav.svg?style=flat-square)](https://github.com/hacdias/webdav/releases/latest)
[![Docker Pulls](https://img.shields.io/docker/pulls/hacdias/webdav?style=flat-square)](https://hub.docker.com/r/hacdias/webdav)
A simple and standalone [WebDAV](https://en.wikipedia.org/wiki/WebDAV) server.
## Install
Please refer to the [Releases page](https://github.com/hacdias/webdav/releases) for more information. There, you can either download the binaries or find the Docker commands to install WebDAV.
For a manual install, please refer to the [releases](https://github.com/hacdias/webdav/releases) page and download the correct binary for your system. Alternatively, you can build or install it from source using the Go toolchain. You can either clone the repository and execute `go build`, or directly install it, using:
```
go install github.com/hacdias/webdav/v5@latest
```
### Homebrew
If you have Homebrew available on your system, you can also install `webdav` using it:
```
brew install webdav
```
### Docker
Docker images are provided on both [GitHub's registry](https://github.com/hacdias/webdav/pkgs/container/webdav) and [Docker Hub](https://hub.docker.com/r/hacdias/webdav). You can pull the images using one of the following two commands. Note that this commands pull the latest released version. You can use specific tags to pin specific versions, or use `main` for the development branch.
```bash
# GitHub Registry
docker pull ghcr.io/hacdias/webdav:latest
# Docker Hub
docker pull hacdias/webdav:latest
```
## Usage
```webdav``` command line interface is really easy to use so you can easily create a WebDAV server for your own user. By default, it runs on a random free port and supports JSON, YAML and TOML configuration. An example of a YAML configuration with the default configurations:
For usage information regarding the CLI, run `webdav --help`.
### Container
To run the container, you can refer to the `compose.yml` file which provides a minimal setup. Additionally, you also need to create a configuration file, as [explained below](#configuration).
The equivalent Docker command to the aforementioned compose file would be as follows:
```bash
docker run \
-p 6065:6065 \
-v ./config.yml:/config.yml:ro \
-v ./data:/data \
ghcr.io/hacdias/webdav -c /config.yml
```
If you are using [fail2ban](#fail2ban-setup), it would be helpful to add the parameters listed below. They will assist in analyzing
the log.
```bash
--log-driver journald \
--name webdav \
```
### Partial updates
This server supports partial file updates compatible with SabreDAV's `PATCH` extension. This is not an official WebDAV specification. Requests must use the `application/x-sabredav-partialupdate` content type, include `Content-Length`, and provide the target range in `X-Update-Range`.
Supported `X-Update-Range` values are:
- `bytes=start-end`
- `bytes=start-`
- `bytes=-N`
- `append`
For clients that use it, the server also supports partial `PUT` requests with `Content-Range`, for example `Content-Range: bytes 6-8/*`. This is an extra compatibility path and should be treated as a client/server agreement.
## Configuration
The configuration can be provided as a YAML, JSON or TOML file. Below is an example of a YAML configuration file with
all the options available, as well as what they mean.
```yaml
# Server related settings
address: 0.0.0.0
port: 0
auth: true
port: 6065
# TLS-related settings if you want to enable TLS directly.
tls: false
cert: cert.pem
key: key.pem
# 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.
# By default it points to the working directory. In the case of the compose file above,
# that is /data.
directory: /data
# Alternatively, replace 'directory' with 'directories' to expose multiple
# directories as virtual root entries. This option is mutually exclusive with
# 'directory' in the same scope. Rules should include the virtual mount name,
# such as /media/public/access/.
# directories:
# - media: /data/media
# - /data/archive
# - name: backups
# path: /data/backups
# The default permissions for users. This is a case insensitive option. Possible
# permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
# permissions. For example, to allow to read and create, set "RC". Default is "R".
# LOCK counts as a write: it needs U on a path that exists and C on one that does
# not, since locking a path that does not exist creates it.
# Being overwritten counts as well: a COPY or MOVE onto an existing file replaces
# it and needs U, while one onto an existing collection removes everything it
# holds and needs D, on the collection and on every path under it.
permissions: R
# The default permissions rules for users. Default is none. Rules are applied
# from last to first, that is, the first rule that matches the request, starting
# from the end, will be applied to the request. Rule paths are always relative to
# the user's directory.
rules: []
# The behavior of redefining the rules for users. It can be:
# - overwrite: when a user has rules defined, these will overwrite any global
# rules already defined. That is, the global rules are not applicable to the
# user.
# - append: when a user has rules defined, these will be appended to the global
# rules already defined. That is, for this user, their own specific rules will
# be checked first, and then the global rules.
# Default is 'overwrite'.
rulesBehavior: overwrite
# Logging configuration
log:
# Logging format ('console', 'json'). Default is 'console'.
format: console
# Enable or disable colors. Default is 'true'. Only applied if format is 'console'.
colors: true
# Logging outputs. You can have more than one output. Default is only 'stderr'.
outputs:
- stderr
# CORS configuration
cors:
- enabled: false
allowed_hosts: []
# Whether or not CORS configuration should be applied. Default is 'false'.
enabled: true
credentials: true
# Allow Private Network Access preflight requests. Default is 'false'.
allow_private_network: false
# The following are the default CORS settings when it is enabled.
allowed_hosts:
- '*'
allowed_headers:
- Authorization
- Content-Type
- Content-Range
- Depth
- Destination
- If
- Lock-Token
- Overwrite
- TimeOut
- Translate
- X-Update-Range
allowed_methods:
- COPY
- DELETE
- GET
- HEAD
- LOCK
- UNLOCK
- MKCOL
- MOVE
- OPTIONS
- PATCH
- POST
- PROPFIND
- PROPPATCH
- PUT
exposed_headers: []
# You define here the list of users.
# Basic authentication is automatically be configured when users are detected
# below, else there will be no authentication.
# Customize to your needs and don't forget to comment out the users you don't need.
users:
# Example 'admin' user with plaintext password.
- username: admin
password: admin
scope: /a/different/path
- username: encrypted
# Example 'john' user with bcrypt encrypted password, with custom directory.
# Tip: you can generate a bcrypt-encrypted password by using the 'webdav bcrypt'
# command lint utility, or htpasswd on Linux.
- username: john
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
directory: /data/john
# Example user whose details will be picked up from the environment.
- username: "{env}ENV_USERNAME"
password: "{env}ENV_PASSWORD"
# Example user with advanced control over his permissions
- username: basic
password: basic
modify: false
permissions: CRUD # Override default permissions.
rules:
- regex: false
allow: false
path: /some/file
# With this rule, the user CANNOT access {user directory}/some/files.
- path: /some/file
permissions: none
# With this rule, the user CAN create, read, update and delete within
# {user directory}/public/access.
- path: /public/access/
permissions: CRUD
# With this rule, the user CAN read and update all files ending with .js.
# It uses a regular expression.
- regex: "^.+.js$"
permissions: RU
# Example user for android SeedVault backuping
- username: android
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
directory: /data/android
permissions: CRUD
# If you're delegating the authentication to a different service, you can proxy
# the username using basic authentication, and then disable webdav's password
# check using the option:
# noPassword: true
```
There are more ways to customize how you run WebDAV through flags and environment variables. Please run `webdav --help` for more information on that.
### Rules
Rules are matched against the request path after dot segments have been resolved, so `/public/../secret/file` is matched as `/secret/file`. The last rule that matches wins.
A `path` rule is a prefix match. A rule written with a trailing slash also covers the collection it names, so `path: /secret/` applies to a request for `/secret` as well. Such a rule can only restrict that collection: acting on the collection itself also requires the permissions that apply outside the rule, since the operation takes place in the parent collection.
A `regex` rule is matched literally against the path, and gets none of the above handling. In particular `regex: "^/secret/"` does **not** match a request for `/secret` itself. Write `regex: "^/secret(/|$)"` if you want to cover the collection too.
Rules apply to every path an operation touches, not only the one it names. Collection listings leave out entries the rules deny, copying a collection leaves those entries behind, and a `MOVE` or `DELETE` that would act on a denied descendant is refused outright.
Overwriting a destination is authorized for what it destroys. A `COPY` or `MOVE` onto an existing destination replaces it: RFC4918 has `MOVE` perform a `DELETE` with `Depth: infinity` on the destination first, and requires an overwritten collection to end up with exactly the membership the source had, so either way whatever was there is gone. Replacing a file needs `U` on it, the same permission `PUT` needs. Replacing a collection removes everything it holds, so it needs `D` on that collection and on every path beneath it: a rule withholding `D` anywhere under a destination refuses the overwrite outright, even where it grants `C` and `U`.
Rules follow the case sensitivity of the file system, which each served directory is probed for at startup. Where names are case-insensitive, as on APFS and NTFS, `path: /secret/` also covers `/SECRET/`, a `regex` is matched against the folded path as well as the path as written, and Unicode normal forms count as one name. Elsewhere rules are matched exactly, since `/secret` and `/SECRET` are then different directories.
### 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 `allow_private_network` to `true` to allow Private-Network-Access preflight requests. Setting `credentials` to `true` will allow you to:
1. Use `withCredentials = true` in javascript.
2. Use the `username:password@host` syntax.
## Caveats
### Reverse Proxy Service
When using a reverse proxy implementation, like Caddy, Nginx, or Apache, note that you need to forward the correct headers in order to avoid 502 errors.
#### Nginx Configuration Example
```nginx
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
# Ensure COPY and MOVE commands work by rewriting the Destination header to
# contain only the path, e.g. /test.txt. Note that the captured group already
# includes the leading slash: adding another one would produce a Destination
# such as //test.txt, which is parsed as a host name and rejected.
set $dest $http_destination;
if ($http_destination ~ "^https?://[^/]+(?<path>/.*)$") {
set $dest $path;
}
proxy_set_header Destination $dest;
}
```
#### Caddy Configuration Example
```Caddyfile
example.com {
tls internal # for local development
# tls [email protected] # so that Caddy gets certs for you via Letsencrypt
# Rewrites destination to remove host and include only the path e.g. /test.txt
@hasDest header_regexp dest ^https?://[^/]+(.*)$
header @hasDest Destination {re.dest.1}
# if running on the same network in docker you can just point to the service name e.g. webdav:6065
reverse_proxy 127.0.0.1:6065 {
header_up X-Real-IP {remote_host}
header_up REMOTE-HOST {remote_host}
}
}
```
#### Serving Under a Subpath
If the server is not served from the root of the domain, do not strip the subpath in the reverse proxy. The server needs to see it: `PROPFIND` responses contain the full path of each resource, and clients reject the ones that fall outside of the URL they requested. Pass the subpath through and set [`prefix`](#configuration) accordingly, so that the server strips it itself and adds it back to the responses:
```yaml
prefix: /webdav
```
With Caddy, that means using `handle` instead of `handle_path`, as the latter strips the matched prefix before proxying:
```Caddyfile
example.com {
@hasDest header_regexp dest ^https?://[^/]+(.*)$
header @hasDest Destination {re.dest.1}
handle /webdav* {
reverse_proxy 127.0.0.1:6065 {
header_up X-Real-IP {remote_host}
header_up REMOTE-HOST {remote_host}
}
}
}
```
With Nginx, use a `location` block for the subpath and keep `proxy_pass` without a trailing path, as a trailing path would replace the prefix:
```nginx
location /webdav {
proxy_pass http://127.0.0.1:6065;
# ... the remaining headers, as above.
}
```
Both the request path and the `Destination` header must carry the prefix. A request without it is answered with `400 Bad Request`.
## 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>:\d+"\s*\}
^.*invalid username\s*\{.*"remote_address":\s*"<HOST>:\d+"\s*\}
ignoreregex =
```
This configuration will capture invalid login attempts and extract the IP address to ban.
#### Jail Configuration
In `jail.d/webdav.conf`, define the jail that monitors your WebDAV log for failed login attempts:
```ini
[webdav]
enabled = true
port = [your_port]
filter = webdav
logpath = [your_log_path]
banaction = iptables-allports
ignoreself = false
```
- Replace `[your_port]` with the port your WebDAV server is running on.
- Replace `[your_log_path]` with the path to your WebDAV log file.
If you use it with Docker and `--log-driver journald`, replace `logpath` with `journalmatch = CONTAINER_NAME=[your_container_name]`
#### Final Steps
1. Restart Fail2Ban to apply these configurations:
```bash
sudo systemctl restart fail2ban
```
2. Verify that Fail2Ban is running and monitoring your WebDAV logs:
```bash
sudo fail2ban-client status webdav
```
With this setup, Fail2Ban will automatically block IP addresses that exceed the allowed number of failed login attempts.
## Contributing
Feel free to open an issue or a pull request.
## License
MIT © [Henrique Dias](https://hacdias.com)
[MIT License](LICENSE) © [Henrique Dias](https://hacdias.com)
+20
View File
@@ -0,0 +1,20 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 5.x | :white_check_mark: |
| < 5.x | :x: |
## Reporting a Vulnerability
- **Critical:** report privately via the [Security](https://github.com/hacdias/webdav/security) page.
- **Non-critical:** open a public issue so the community can help; it'll be labeled it as a security issue.
Please include, where possible:
- The commit the issue was found at
- A plaintext proof of concept (no binaries)
- Steps to reproduce
- Recommended remediation, if any
+49
View File
@@ -0,0 +1,49 @@
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"golang.org/x/crypto/bcrypt"
)
func init() {
flags := bcryptCmd.Flags()
flags.IntP("cost", "c", bcrypt.DefaultCost, "cost used to generate password, higher cost leads to slower verification times")
rootCmd.AddCommand(bcryptCmd)
}
var bcryptCmd = &cobra.Command{
Use: "bcrypt",
Short: "Generate a bcrypt encrypted password",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cost, err := cmd.Flags().GetInt("cost")
if err != nil {
return err
}
if cost < bcrypt.MinCost {
return fmt.Errorf("given cost cannot be under minimum cost of %d", bcrypt.MinCost)
}
if cost > bcrypt.MaxCost {
return fmt.Errorf("given cost cannot be over maximum cost of %d", bcrypt.MaxCost)
}
pwd := args[0]
if pwd == "" {
return errors.New("password argument must not be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), cost)
if err != nil {
return err
}
fmt.Println(string(hash))
return nil
},
}
-186
View File
@@ -1,186 +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 parseCors(raw []interface{}, c *webdav.Config) {
hosts := []string{}
for _, v := range raw {
if cfg, ok := v.(map[interface{}]interface{}); ok {
cors := webdav.CorsCfg{
Enabled: cfg["enabled"].(bool),
AllowedHosts: []string{},
}
if allowedHosts, ok := cfg["allowed_hosts"]; ok {
hosts = append(hosts, strings.Split(allowedHosts.(string), ",")...)
}
if len(hosts) == 0 {
hosts = append(hosts, "*")
}
cors.AllowedHosts = hosts
c.Cors = cors
}
}
}
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"),
Cors: webdav.CorsCfg{
Enabled: false,
AllowedHosts: []string{},
},
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)
}
rawCors := v.Get("cors")
if cors, ok := rawCors.([]interface{}); ok {
parseCors(cors, cfg)
}
if len(cfg.Users) != 0 && !cfg.Auth {
log.Print("Users will be ignored due to auth=false")
}
return cfg
}
+96 -49
View File
@@ -1,30 +1,31 @@
package cmd
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"github.com/coreos/go-systemd/v22/activation"
"github.com/hacdias/webdav/v5/lib"
"github.com/spf13/cobra"
v "github.com/spf13/viper"
)
var (
cfgFile string
"go.uber.org/zap"
)
func init() {
cobra.OnInitialize(initConfig)
flags := rootCmd.Flags()
flags.StringVarP(&cfgFile, "config", "c", "", "config file path")
flags.BoolP("tls", "t", false, "enable tls")
flags.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 +47,99 @@ The precedence of the configuration values are as follows:
The environment variables are prefixed by "WD_" followed by the option
name in caps. So to set "cert" via an env variable, you should
set WD_CERT.`,
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
flags := cmd.Flags()
cfg := readConfig(flags)
cfgFilename, _ := flags.GetString("config")
// Builds the address and a listener.
laddr := getOpt(flags, "address") + ":" + getOpt(flags, "port")
listener, err := net.Listen("tcp", laddr)
cfg, err := lib.ParseConfig(cfgFilename, flags)
if err != nil {
log.Fatal(err)
return err
}
// Tell the user the port in which is listening.
fmt.Println("Listening on", listener.Addr().String())
// Starts the server.
if getOptB(flags, "tls") {
if err := http.ServeTLS(listener, cfg, getOpt(flags, "cert"), getOpt(flags, "key")); err != nil {
log.Fatal(err)
}
} else {
if err := http.Serve(listener, cfg); err != nil {
log.Fatal(err)
}
// Setup the logger based on the configuration
logger, err := cfg.GetLogger()
if err != nil {
return err
}
zap.ReplaceGlobals(logger)
// Create HTTP handler from the config
handler, err := lib.NewHandler(cfg)
if err != nil {
return err
}
defer func() {
// Flush the logger at the end
_ = zap.L().Sync()
}()
// Build listener
listener, err := getListener(cfg)
if err != nil {
return err
}
server := &http.Server{Handler: handler}
// Trap exiting signals
quit := make(chan os.Signal, 1)
go func() {
zap.L().Info("listening", zap.String("address", listener.Addr().String()))
var err error
if cfg.TLS {
err = server.ServeTLS(listener, cfg.Cert, cfg.Key)
} else {
err = server.Serve(listener)
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
zap.L().Error("failed to start server", zap.Error(err))
}
quit <- os.Interrupt
}()
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
signal := <-quit
zap.L().Info("caught signal, shutting down", zap.Stringer("signal", signal))
_ = server.Shutdown(context.Background())
return nil
},
}
func initConfig() {
if cfgFile == "" {
v.AddConfigPath(".")
v.AddConfigPath("/etc/webdav/")
v.SetConfigName("config")
} else {
v.SetConfigFile(cfgFile)
}
func getListener(cfg *lib.Config) (net.Listener, error) {
var (
address string
network string
)
v.SetEnvPrefix("WD")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(v.ConfigParseError); ok {
panic(err)
if strings.HasPrefix(cfg.Address, "sd-listen-fd:") {
listeners, err := activation.ListenersWithNames()
if err != nil {
return nil, err
}
cfgFile = "No config file used"
address := cfg.Address[13:]
listener, ok := listeners[address]
if !ok || len(listener) < 1 {
return nil, errors.New("unknown sd-listen-fd address '" + address + "'")
}
return listener[0], nil
} else if strings.HasPrefix(cfg.Address, "unix:") {
address = cfg.Address[5:]
network = "unix"
} else {
cfgFile = "Using config file: " + v.ConfigFileUsed()
address = fmt.Sprintf("%s:%d", cfg.Address, cfg.Port)
network = "tcp"
}
return net.Listen(network, address)
}
+2 -2
View File
@@ -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
View File
@@ -1,55 +0,0 @@
package cmd
import (
"log"
"github.com/spf13/pflag"
v "github.com/spf13/viper"
)
// getOption returns a parameter as a string.
//
// NOTE: we could simply bind the flags to viper and use IsSet.
// Although there is a bug on Viper that always returns true on IsSet
// if a flag is binded. Our alternative way is to manually check
// the flag and then the value from env/config/gotten by viper.
// https://github.com/spf13/viper/pull/331
func getOpt(flags *pflag.FlagSet, key string) string {
value, _ := flags.GetString(key)
// If set on Flags, use it.
if flags.Changed(key) {
return value
}
// If set through viper (env, config), return it.
if v.IsSet(key) {
return v.GetString(key)
}
// Otherwise use default value on flags.
return value
}
func getOptB(flags *pflag.FlagSet, key string) bool {
value, _ := flags.GetBool(key)
// If set on Flags, use it.
if flags.Changed(key) {
return value
}
// If set through viper (env, config), return it.
if v.IsSet(key) {
return v.GetBool(key)
}
// Otherwise use default value on flags.
return value
}
func checkErr(err error) {
if err != nil {
log.Fatal(err)
}
}
+14
View File
@@ -0,0 +1,14 @@
name: webdav
services:
app:
container_name: webdav
image: 'webdav:test'
ports:
- '6065:6065'
security_opt:
- label=type:container_runtime_t
restart: always
volumes:
- ./data:/data
- ./config.yml:/config.yml:ro
+29 -9
View File
@@ -1,13 +1,33 @@
module github.com/hacdias/webdav
module github.com/hacdias/webdav/v5
go 1.12
go 1.26.0
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.7.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/rs/cors v1.11.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
github.com/studio-b12/gowebdav v0.13.0
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.57.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1
golang.org/x/net v0.59.0
golang.org/x/text v0.42.0
)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.48.0 // indirect
)
+64 -54
View File
@@ -1,55 +1,65 @@
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/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/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/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1 h1:n8oH0y5uJlfek8vblc51aexgj0qKjh95bYIANGCaxoI=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
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=
+76
View File
@@ -0,0 +1,76 @@
package lib
import (
"os"
"path/filepath"
"runtime"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// defaultCaseInsensitiveFS is true on platforms that are case-insensitive by
// default, and false on platforms that are case-sensitive by default. It is used
// as a fallback when probing the file system fails.
const defaultCaseInsensitiveFS = runtime.GOOS == "darwin" || runtime.GOOS == "windows"
// foldPath is used to compare paths in a case-insensitive manner, normalizing
// them to NFC and converting to lower case.
func foldPath(p string) string {
return norm.NFC.String(strings.ToLower(p))
}
// caseInsensitiveFS probes the file system backing dir to see if it is case
// insensitive. It falls back to the platform default when the probe fails.
func caseInsensitiveFS(dir string) bool {
info, err := os.Stat(dir)
if err != nil {
return defaultCaseInsensitiveFS
}
flipped, ok := flipCase(dir)
if !ok {
return defaultCaseInsensitiveFS
}
other, err := os.Stat(flipped)
if err != nil {
return false
}
return os.SameFile(info, other)
}
// flipCase swaps the case of the first letter in the last element of dir whose
// case mapping round-trips, reporting false when it holds none.
func flipCase(dir string) (string, bool) {
flipped := false
base := strings.Map(func(r rune) rune {
if flipped {
return r
}
switch {
case unicode.IsLower(r):
if u := unicode.ToUpper(r); unicode.ToLower(u) == r {
flipped = true
return u
}
case unicode.IsUpper(r):
if l := unicode.ToLower(r); unicode.ToUpper(l) == r {
flipped = true
return l
}
}
return r
}, filepath.Base(dir))
if !flipped {
return "", false
}
return filepath.Join(filepath.Dir(dir), base), true
}
+393
View File
@@ -0,0 +1,393 @@
package lib
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/go-viper/mapstructure/v2"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
const (
DefaultTLS = false
DefaultCert = "cert.pem"
DefaultKey = "key.pem"
DefaultAddress = "0.0.0.0"
DefaultPort = 6065
DefaultPrefix = "/"
)
var errDirectoryConflict = errors.New("directory and directories cannot both be defined")
type Config struct {
UserPermissions `mapstructure:",squash"`
Debug bool
Address string
Port int
TLS bool
Cert string
Key string
Prefix string
NoSniff bool
NoPassword bool
BehindProxy bool
Log Log
CORS CORS
Users []User
}
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
v := viper.NewWithOptions(viper.ExperimentalBindStruct())
// Configure flags bindings
if flags != nil {
err := v.BindPFlags(flags)
if err != nil {
return nil, err
}
}
// Configuration file settings
v.AddConfigPath(".")
v.AddConfigPath("/etc/webdav/")
if path, err := os.Executable(); err == nil {
v.AddConfigPath(filepath.Dir(path))
}
v.SetConfigName("config")
if filename != "" {
v.SetConfigFile(filename)
}
// Environment settings
v.SetEnvPrefix("wd")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// Defaults shared with flags
v.SetDefault("TLS", DefaultTLS)
v.SetDefault("Cert", DefaultCert)
v.SetDefault("Key", DefaultKey)
v.SetDefault("Address", DefaultAddress)
v.SetDefault("Port", DefaultPort)
v.SetDefault("Prefix", DefaultPrefix)
// Other defaults
v.SetDefault("RulesBehavior", RulesOverwrite)
v.SetDefault("Directory", ".")
v.SetDefault("Permissions", "R")
v.SetDefault("Debug", false)
v.SetDefault("NoSniff", false)
v.SetDefault("NoPassword", false)
v.SetDefault("Log.Format", "console")
v.SetDefault("Log.Outputs", []string{"stderr"})
v.SetDefault("Log.Colors", true)
v.SetDefault("CORS.Allowed_Hosts", []string{"*"})
v.SetDefault("CORS.Allowed_Headers", []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"})
v.SetDefault("CORS.Allowed_Methods", []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"})
// Read and unmarshal configuration
err := v.ReadInConfig()
if err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, err
}
}
cfg := &Config{}
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
directoryMountsDecodeHook(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
mapstructure.TextUnmarshallerHookFunc(),
)))
if err != nil {
return nil, err
}
err = applyDirectoryConfig(v, flags, &cfg.UserPermissions, "directory", "directories", nil)
if err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
// Cascade user settings
for i := range cfg.Users {
userDirectoryKey := fmt.Sprintf("Users.%d.Directory", i)
userDirectoriesKey := fmt.Sprintf("Users.%d.Directories", i)
if !v.IsSet(userDirectoryKey) {
cfg.Users[i].Directory = cfg.Directory
}
err := applyDirectoryConfig(v, flags, &cfg.Users[i].UserPermissions, userDirectoryKey, userDirectoriesKey, &cfg.UserPermissions)
if err != nil {
if errors.Is(err, errDirectoryConflict) {
return nil, fmt.Errorf("invalid config: user %q cannot define both directory and directories", cfg.Users[i].Username)
}
return nil, fmt.Errorf("invalid config: user %q: %w", cfg.Users[i].Username, err)
}
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
cfg.Users[i].Permissions = cfg.Permissions
}
if !v.IsSet(fmt.Sprintf("Users.%d.RulesBehavior", i)) {
cfg.Users[i].RulesBehavior = cfg.RulesBehavior
}
if v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
switch cfg.Users[i].RulesBehavior {
case RulesOverwrite:
// Do nothing
case RulesAppend:
rules := append([]*Rule{}, cfg.Rules...)
rules = append(rules, cfg.Users[i].Rules...)
cfg.Users[i].Rules = rules
}
} else {
cfg.Users[i].Rules = cfg.Rules
}
}
err = cfg.Validate()
if err != nil {
return nil, err
}
return cfg, nil
}
func applyDirectoryConfig(v *viper.Viper, flags *pflag.FlagSet, permissions *UserPermissions, directoryKey, directoriesKey string, inherited *UserPermissions) error {
permissions.directoryExplicit = isExplicitlySet(v, flags, directoryKey)
permissions.directoriesExplicit = isExplicitlySet(v, flags, directoriesKey)
if permissions.directoryExplicit && permissions.directoriesExplicit {
return errDirectoryConflict
}
switch {
case permissions.directoryExplicit:
permissions.Directory = v.GetString(directoryKey)
permissions.useDirectories = false
case permissions.directoriesExplicit:
directories, err := getDirectoryMounts(v, directoriesKey, permissions.Directories)
if err != nil {
return err
}
permissions.Directories = directories
permissions.useDirectories = true
case inherited != nil:
permissions.Directories = append(DirectoryMounts{}, inherited.Directories...)
permissions.useDirectories = inherited.useDirectories
}
return nil
}
func isExplicitlySet(v *viper.Viper, flags *pflag.FlagSet, key string) bool {
if flags != nil && flags.Changed(key) {
return true
}
if v.InConfig(key) {
return true
}
envKey := "WD_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
value, ok := os.LookupEnv(envKey)
return ok && value != ""
}
func (c *Config) Validate() error {
var err error
c.Directory, err = filepath.Abs(c.Directory)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
if c.TLS {
if c.Cert == "" {
return errors.New("invalid config: Cert must be defined if TLS is activated")
}
if c.Key == "" {
return errors.New("invalid config: Key must be defined if TLS is activated")
}
c.Cert, err = filepath.Abs(c.Cert)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
c.Key, err = filepath.Abs(c.Key)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
}
err = c.UserPermissions.Validate()
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
for i := range c.Users {
err := c.Users[i].Validate(c.NoPassword)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
}
return nil
}
func directoryMountsDecodeHook() mapstructure.DecodeHookFunc {
mountsType := reflect.TypeOf(DirectoryMounts{})
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
if to != mountsType {
return data, nil
}
return decodeDirectoryMounts(data)
}
}
func getDirectoryMounts(v *viper.Viper, key string, fallback DirectoryMounts) (DirectoryMounts, error) {
value := v.Get(key)
if value == nil {
return fallback, nil
}
return decodeDirectoryMounts(value)
}
func decodeDirectoryMounts(data any) (DirectoryMounts, error) {
switch value := data.(type) {
case nil:
return DirectoryMounts{}, nil
case DirectoryMounts:
return value, nil
case []DirectoryMount:
return DirectoryMounts(value), nil
case string:
if value == "" {
return DirectoryMounts{}, nil
}
parts := strings.Split(value, ",")
mounts := make(DirectoryMounts, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
mounts = append(mounts, DirectoryMount{Path: part})
}
return mounts, nil
case []any:
mounts := make(DirectoryMounts, 0, len(value))
for _, item := range value {
mount, err := decodeDirectoryMount(item)
if err != nil {
return nil, err
}
mounts = append(mounts, mount)
}
return mounts, nil
case []string:
mounts := make(DirectoryMounts, 0, len(value))
for _, item := range value {
mounts = append(mounts, DirectoryMount{Path: item})
}
return mounts, nil
default:
return nil, fmt.Errorf("invalid directories: unsupported value %T", data)
}
}
func decodeDirectoryMount(data any) (DirectoryMount, error) {
switch value := data.(type) {
case string:
return DirectoryMount{Path: value}, nil
case map[string]any:
return decodeDirectoryMountMap(value)
case map[any]any:
m := map[string]any{}
for key, value := range value {
keyString, ok := key.(string)
if !ok {
return DirectoryMount{}, errors.New("invalid directories: mount keys must be strings")
}
m[keyString] = value
}
return decodeDirectoryMountMap(m)
default:
return DirectoryMount{}, fmt.Errorf("invalid directories: unsupported mount entry %T", data)
}
}
func decodeDirectoryMountMap(data map[string]any) (DirectoryMount, error) {
_, hasName := data["name"]
_, hasPath := data["path"]
if hasName || hasPath {
name, nameOK := data["name"].(string)
path, pathOK := data["path"].(string)
if !nameOK || !pathOK || len(data) != 2 {
return DirectoryMount{}, errors.New("invalid directories: explicit mount objects must define name and path")
}
return DirectoryMount{Name: name, Path: path}, nil
}
if len(data) != 1 {
return DirectoryMount{}, errors.New("invalid directories: mapped mount entries must have exactly one key")
}
for name, path := range data {
pathString, ok := path.(string)
if !ok {
return DirectoryMount{}, errors.New("invalid directories: mapped mount paths must be strings")
}
return DirectoryMount{Name: name, Path: pathString}, nil
}
return DirectoryMount{}, errors.New("invalid directories: empty mount entry")
}
func (cfg *Config) GetLogger() (*zap.Logger, error) {
loggerConfig := zap.NewProductionConfig()
loggerConfig.DisableCaller = true
if cfg.Debug {
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
}
if cfg.Log.Colors && cfg.Log.Format != "json" {
loggerConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
loggerConfig.Encoding = cfg.Log.Format
loggerConfig.OutputPaths = cfg.Log.Outputs
return loggerConfig.Build()
}
type Log struct {
Format string
Colors bool
Outputs []string
}
type CORS struct {
Enabled bool
Credentials bool
AllowPrivateNetwork bool `mapstructure:"allow_private_network"`
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowedHosts []string `mapstructure:"allowed_hosts"`
AllowedMethods []string `mapstructure:"allowed_methods"`
ExposedHeaders []string `mapstructure:"exposed_headers"`
}
+544
View File
@@ -0,0 +1,544 @@
package lib
import (
"os"
"path/filepath"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeAndParseConfig(t *testing.T, content, extension string) *Config {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "config"+extension)
err := os.WriteFile(tmpFile, []byte(content), 0666)
require.NoError(t, err)
cfg, err := ParseConfig(tmpFile, nil)
require.NoError(t, err)
return cfg
}
func writeAndParseConfigWithError(t *testing.T, content, extension, error string) {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "config"+extension)
err := os.WriteFile(tmpFile, []byte(content), 0666)
require.NoError(t, err)
_, err = ParseConfig(tmpFile, nil)
require.ErrorContains(t, err, error)
}
func TestConfigDefaults(t *testing.T) {
t.Parallel()
cfg := writeAndParseConfig(t, "", ".yml")
require.NoError(t, cfg.Validate())
require.EqualValues(t, DefaultTLS, cfg.TLS)
require.EqualValues(t, DefaultAddress, cfg.Address)
require.EqualValues(t, DefaultPort, cfg.Port)
require.EqualValues(t, DefaultPrefix, cfg.Prefix)
require.EqualValues(t, "console", cfg.Log.Format)
require.EqualValues(t, true, cfg.Log.Colors)
require.EqualValues(t, []string{"stderr"}, cfg.Log.Outputs)
dir, err := os.Getwd()
require.NoError(t, err)
require.Equal(t, dir, cfg.Directory)
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
require.EqualValues(t, []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}, cfg.CORS.AllowedMethods)
require.False(t, cfg.CORS.AllowPrivateNetwork)
}
func TestConfigCascade(t *testing.T) {
t.Parallel()
// Directories are resolved to absolute paths, which differ by platform
// (for example "/" becomes the current drive root on Windows).
rootDirectory, err := filepath.Abs("/")
require.NoError(t, err)
basicDirectory, err := filepath.Abs("/basic")
require.NoError(t, err)
check := func(t *testing.T, cfg *Config) {
require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
require.False(t, cfg.Permissions.Delete)
require.False(t, cfg.Permissions.Update)
require.Equal(t, rootDirectory, cfg.Directory)
require.Len(t, cfg.Rules, 1)
require.Len(t, cfg.Users, 2)
require.True(t, cfg.Users[0].Permissions.Read)
require.True(t, cfg.Users[0].Permissions.Create)
require.False(t, cfg.Users[0].Permissions.Delete)
require.False(t, cfg.Users[0].Permissions.Update)
require.Equal(t, rootDirectory, cfg.Users[0].Directory)
require.Len(t, cfg.Users[0].Rules, 1)
require.True(t, cfg.Users[1].Permissions.Read)
require.False(t, cfg.Users[1].Permissions.Create)
require.False(t, cfg.Users[1].Permissions.Delete)
require.False(t, cfg.Users[1].Permissions.Update)
require.Equal(t, basicDirectory, cfg.Users[1].Directory)
require.Len(t, cfg.Users[1].Rules, 0)
}
t.Run("YAML", func(t *testing.T) {
content := `
directory: /
permissions: CR
rules:
- path: /public/access/
permissions: R
users:
- username: admin
password: admin
- username: basic
password: basic
directory: /basic
permissions: R
rules: []`
cfg := writeAndParseConfig(t, content, ".yml")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
t.Run("JSON", func(t *testing.T) {
content := `{
"directory": "/",
"permissions": "CR",
"rules": [
{
"path": "/public/access/",
"permissions": "R"
}
],
"users": [
{
"username": "admin",
"password": "admin"
},
{
"username": "basic",
"password": "basic",
"directory": "/basic",
"permissions": "R",
"rules": []
}
]
}`
cfg := writeAndParseConfig(t, content, ".json")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
t.Run("`TOML", func(t *testing.T) {
content := `
directory = "/"
permissions = "CR"
[[rules]]
path = "/public/access/"
permissions = "R"
[[users]]
username = "admin"
password = "admin"
[[users]]
username = "basic"
password = "basic"
directory = "/basic"
permissions = "R"
rules = []
`
cfg := writeAndParseConfig(t, content, ".toml")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
}
func TestConfigDirectories(t *testing.T) {
t.Parallel()
t.Run("Mixed Entries", func(t *testing.T) {
t.Parallel()
dirC := t.TempDir()
dirD := t.TempDir()
dirE := t.TempDir()
cfg := writeAndParseConfig(t, `
directories:
- `+dirC+`
- d2: `+dirD+`
- name: archive
path: `+dirE+`
`, ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
require.Equal(t, dirC, cfg.Directories[0].Path)
require.Equal(t, "d2", cfg.Directories[1].Name)
require.Equal(t, dirD, cfg.Directories[1].Path)
require.Equal(t, "archive", cfg.Directories[2].Name)
require.Equal(t, dirE, cfg.Directories[2].Path)
})
t.Run("JSON", func(t *testing.T) {
t.Parallel()
dirC := t.TempDir()
dirD := t.TempDir()
dirE := t.TempDir()
cfg := writeAndParseConfig(t, `{
"directories": [
`+strconv.Quote(dirC)+`,
{ "d2": `+strconv.Quote(dirD)+` },
{ "name": "archive", "path": `+strconv.Quote(dirE)+` }
]
}`, ".json")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
require.Equal(t, dirC, cfg.Directories[0].Path)
require.Equal(t, "d2", cfg.Directories[1].Name)
require.Equal(t, dirD, cfg.Directories[1].Path)
require.Equal(t, "archive", cfg.Directories[2].Name)
require.Equal(t, dirE, cfg.Directories[2].Path)
})
t.Run("TOML", func(t *testing.T) {
t.Parallel()
dirD := t.TempDir()
dirE := t.TempDir()
cfg := writeAndParseConfig(t, `
[[directories]]
d2 = `+strconv.Quote(dirD)+`
[[directories]]
name = "archive"
path = `+strconv.Quote(dirE)+`
`, ".toml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, "d2", cfg.Directories[0].Name)
require.Equal(t, dirD, cfg.Directories[0].Path)
require.Equal(t, "archive", cfg.Directories[1].Name)
require.Equal(t, dirE, cfg.Directories[1].Path)
})
t.Run("Mutually Exclusive Global Directory Fields", func(t *testing.T) {
t.Parallel()
writeAndParseConfigWithError(t, `
directory: /tmp
directories:
- /tmp
`, ".yml", "directory and directories cannot both be defined")
})
t.Run("Mutually Exclusive User Directory Fields", func(t *testing.T) {
t.Parallel()
writeAndParseConfigWithError(t, `
users:
- username: basic
password: basic
directory: /tmp
directories:
- /tmp
`, ".yml", "cannot define both directory and directories")
})
t.Run("Duplicate Mount Names", func(t *testing.T) {
t.Parallel()
parent := t.TempDir()
dir := filepath.Join(parent, "dup")
require.NoError(t, os.Mkdir(dir, 0775))
writeAndParseConfigWithError(t, `
directories:
- `+dir+`
- dup: /tmp
`, ".yml", "duplicate mount name")
})
t.Run("Cascade Mode", func(t *testing.T) {
t.Parallel()
global := t.TempDir()
single := t.TempDir()
userMulti := t.TempDir()
cfg := writeAndParseConfig(t, `
directories:
- global: `+global+`
users:
- username: inherited
password: inherited
- username: single
password: single
directory: `+single+`
- username: multi
password: multi
directories:
- owned: `+userMulti+`
`, ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.True(t, cfg.Users[0].useDirectories)
require.Equal(t, DirectoryMounts{{Name: "global", Path: global}}, cfg.Users[0].Directories)
require.False(t, cfg.Users[1].useDirectories)
require.Equal(t, single, cfg.Users[1].Directory)
require.True(t, cfg.Users[2].useDirectories)
require.Equal(t, DirectoryMounts{{Name: "owned", Path: userMulti}}, cfg.Users[2].Directories)
})
}
func TestConfigDirectoriesEnvOverrides(t *testing.T) {
global := t.TempDir()
single := t.TempDir()
userMulti := t.TempDir()
t.Setenv("WD_DIRECTORIES", global)
t.Setenv("WD_USERS_1_DIRECTORY", single)
t.Setenv("WD_USERS_2_DIRECTORIES", userMulti)
cfg := writeAndParseConfig(t, `
users:
- username: inherited
password: inherited
- username: single
password: single
- username: multi
password: multi
`, ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Directories)
require.True(t, cfg.Users[0].useDirectories)
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Users[0].Directories)
require.False(t, cfg.Users[1].useDirectories)
require.Equal(t, single, cfg.Users[1].Directory)
require.True(t, cfg.Users[2].useDirectories)
require.Equal(t, DirectoryMounts{{Name: filepath.Base(userMulti), Path: userMulti}}, cfg.Users[2].Directories)
}
func TestConfigKeys(t *testing.T) {
t.Parallel()
cfg := writeAndParseConfig(t, `
cors:
enabled: true
credentials: true
allow_private_network: 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.True(t, cfg.CORS.AllowPrivateNetwork)
require.EqualValues(t, []string{"Content-Length", "Content-Range"}, cfg.CORS.ExposedHeaders)
require.EqualValues(t, []string{"Depth"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"http://localhost:8080"}, cfg.CORS.AllowedHosts)
require.EqualValues(t, []string{"GET"}, cfg.CORS.AllowedMethods)
}
func TestConfigRules(t *testing.T) {
t.Run("Only Regex or Path", func(t *testing.T) {
content := `
directory: /
rules:
- regex: '^.+\.js$'
path: /public/access/`
writeAndParseConfigWithError(t, content, ".yaml", "cannot define both regex and path")
})
t.Run("Regex or Path Required", func(t *testing.T) {
content := `
directory: /
rules:
- permissions: CRUD`
writeAndParseConfigWithError(t, content, ".yaml", "must either define a path of a regex")
})
t.Run("Parse", func(t *testing.T) {
content := `
directory: /
rules:
- regex: '^.+\.js$'
- path: /public/access/`
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
require.Len(t, cfg.Rules, 2)
require.Empty(t, cfg.Rules[0].Path)
require.NotNil(t, cfg.Rules[0].Regex)
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
require.NotEmpty(t, cfg.Rules[1].Path)
require.Nil(t, cfg.Rules[1].Regex)
})
t.Run("Rules Behavior (Default: Overwrite)", func(t *testing.T) {
content := `
directory: /
rules:
- regex: '^.+\.js$'
- path: /public/access/
users:
- username: foo
password: bar
rules:
- path: /private/access/`
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
require.Len(t, cfg.Rules, 2)
require.Empty(t, cfg.Rules[0].Path)
require.NotNil(t, cfg.Rules[0].Regex)
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
require.EqualValues(t, "/public/access/", cfg.Rules[1].Path)
require.Nil(t, cfg.Rules[1].Regex)
require.Len(t, cfg.Users, 1)
require.Len(t, cfg.Users[0].Rules, 1)
require.EqualValues(t, "/private/access/", cfg.Users[0].Rules[0].Path)
})
t.Run("Rules Behavior (Append)", func(t *testing.T) {
content := `
directory: /
rules:
- regex: '^.+\.js$'
- path: /public/access/
rulesBehavior: append
users:
- username: foo
password: bar
rules:
- path: /private/access/`
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
require.Len(t, cfg.Rules, 2)
require.Empty(t, cfg.Rules[0].Path)
require.NotNil(t, cfg.Rules[0].Regex)
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
require.EqualValues(t, "/public/access/", cfg.Rules[1].Path)
require.Nil(t, cfg.Rules[1].Regex)
require.Len(t, cfg.Users, 1)
require.Len(t, cfg.Users[0].Rules, 3)
require.EqualValues(t, cfg.Rules[0], cfg.Users[0].Rules[0])
require.EqualValues(t, cfg.Rules[1], cfg.Users[0].Rules[1])
require.EqualValues(t, "/private/access/", cfg.Users[0].Rules[2].Path)
})
}
func TestConfigEnv(t *testing.T) {
require.NoError(t, os.Setenv("WD_PORT", "1234"))
require.NoError(t, os.Setenv("WD_DEBUG", "true"))
require.NoError(t, os.Setenv("WD_PERMISSIONS", "CRUD"))
require.NoError(t, os.Setenv("WD_DIRECTORY", "/test"))
cfg, err := ParseConfig("", nil)
require.NoError(t, err)
expectedDirectory, err := filepath.Abs("/test")
require.NoError(t, err)
assert.Equal(t, 1234, cfg.Port)
assert.Equal(t, expectedDirectory, cfg.Directory)
assert.Equal(t, true, cfg.Debug)
require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
require.True(t, cfg.Permissions.Delete)
require.True(t, cfg.Permissions.Update)
// Reset
require.NoError(t, os.Setenv("WD_PORT", ""))
require.NoError(t, os.Setenv("WD_DEBUG", ""))
require.NoError(t, os.Setenv("WD_PERMISSIONS", ""))
require.NoError(t, os.Setenv("WD_DIRECTORY", ""))
}
func TestConfigParseUserPasswordEnvironment(t *testing.T) {
content := `
directory: /
users:
- username: '{env}USER1_USERNAME'
password: '{env}USER1_PASSWORD'
- username: basic
password: basic
`
writeAndParseConfigWithError(t, content, ".yml", "username environment variable is empty")
err := os.Setenv("USER1_USERNAME", "admin")
require.NoError(t, err)
writeAndParseConfigWithError(t, content, ".yml", "password environment variable is empty")
err = os.Setenv("USER1_PASSWORD", "admin")
require.NoError(t, err)
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
require.Equal(t, "admin", cfg.Users[0].Username)
require.Equal(t, "basic", cfg.Users[1].Username)
require.True(t, cfg.Users[0].checkPassword("admin"))
require.True(t, cfg.Users[1].checkPassword("basic"))
}
+82
View 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.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
}
+300
View File
@@ -0,0 +1,300 @@
package lib
import (
"net/http"
"os"
"github.com/rs/cors"
"go.uber.org/zap"
"golang.org/x/net/webdav"
)
type handlerUser struct {
User
handler webdav.Handler
fs permissionsFS
}
type Handler struct {
noPassword bool
behindProxy bool
user *handlerUser
users map[string]*handlerUser
}
func NewHandler(c *Config) (http.Handler, error) {
ls := webdav.NewMemLS()
logFunc := func(r *http.Request, err error) {
lZap := getRequestLogger(r, c.BehindProxy)
lZap.Debug("handle webdav request", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Error(err))
}
h := &Handler{
noPassword: c.NoPassword,
behindProxy: c.BehindProxy,
user: newHandlerUser(User{UserPermissions: c.UserPermissions}, c, ls, logFunc),
users: map[string]*handlerUser{},
}
for _, u := range c.Users {
h.users[u.Username] = newHandlerUser(u, c, ls, logFunc)
}
if c.CORS.Enabled {
return cors.New(cors.Options{
AllowCredentials: c.CORS.Credentials,
AllowPrivateNetwork: c.CORS.AllowPrivateNetwork,
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
}
// newHandlerUser prepares a user for serving, keeping the unwrapped file system
// alongside the handler.
func newHandlerUser(u User, c *Config, ls webdav.LockSystem, logFunc func(*http.Request, error)) *handlerUser {
fs := permissionsFS{fs: buildFileSystem(u.UserPermissions, c.NoSniff), perms: u.UserPermissions}
return &handlerUser{
User: u,
handler: buildWebdavHandler(u.UserPermissions, fs, c.Prefix, ls, logFunc),
fs: fs,
}
}
// buildFileSystem creates the unfiltered [webdav.FileSystem] for a set of user
// permissions, selecting between single-directory and multi-directory backing
// depending on whether directories are configured.
func buildFileSystem(p UserPermissions, noSniff bool) webdav.FileSystem {
if p.useDirectories {
return multiDir{
mounts: p.Directories,
noSniff: noSniff,
}
}
return Dir{
Dir: webdav.Dir(p.Directory),
noSniff: noSniff,
}
}
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions.
func buildWebdavHandler(p UserPermissions, fs permissionsFS, prefix string, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
h := webdav.Handler{
Prefix: prefix,
Logger: logFunc,
FileSystem: fs,
}
if p.useDirectories {
h.LockSystem = newMultiDirLockSystem(ls, p.Directories)
} else {
h.LockSystem = newLockSystem(ls, p.Directory)
}
return h
}
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := h.user
lZap := getRequestLogger(r, h.behindProxy)
// Authentication
if len(h.users) > 0 {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
// Gets the correct user for this request.
username, password, ok := r.BasicAuth()
if !ok {
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
user, ok = h.users[username]
if !ok {
// Log invalid username
lZap.Info("invalid username", zap.String("username", username))
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
if !h.noPassword && !user.checkPassword(password) {
// Log invalid password
lZap.Info("invalid password", zap.String("username", username))
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
// Log successful authorization
lZap.Info("user authorized", zap.String("username", username))
}
// Convert the HTTP request into an internal request type
req, err := newRequest(r, h.user.handler.Prefix)
if err != nil {
lZap.Info("invalid request path or destination", zap.Error(err))
http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
return
}
fileExists := func(filename string) bool {
_, err := user.fs.Stat(r.Context(), filename)
return !os.IsNotExist(err)
}
// Checks for user permissions relatively to this PATH.
allowed := user.Allowed(req, fileExists)
lZap.Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
if !allowed {
w.WriteHeader(http.StatusForbidden)
return
}
// MOVE and DELETE act on a whole source subtree in one call, so every
// descendant needs authorizing here. Reading COPY and PROPFIND out of the
// source goes through permFS instead.
if r.Method == "MOVE" || r.Method == "DELETE" {
ok, err := user.fs.allowedThroughout(r.Context(), req.path, func(p Permissions) bool {
return p.Allowed(req, fileExists)
})
if err != nil {
lZap.Error("could not authorize subtree", zap.String("path", req.path), zap.Error(err))
w.WriteHeader(http.StatusForbidden)
return
}
if !ok {
lZap.Info("denied by a rule on a descendant", zap.String("method", r.Method), zap.String("path", req.path))
w.WriteHeader(http.StatusForbidden)
return
}
}
// Excerpt from RFC4918, section 9.9.3, on MOVE:
//
// If a resource exists at the destination and the Overwrite header is
// "T", then prior to performing the move, the server MUST perform a
// DELETE with "Depth: infinity" on the destination resource.
//
// And from section 9.8.4, on COPY:
//
// When a collection is overwritten, the membership of the destination
// collection after the successful COPY request MUST be the same
// membership as the source collection immediately before the COPY.
//
// Either way whatever the destination collection held is gone, which
// golang.org/x/net/webdav carries out as a RemoveAll before the rename or
// the copy. Writing over a file is an update, already authorized by Allowed,
// but removing a collection takes everything under it. Nothing writes to
// those descendants, they are only destroyed, so authorize the destination
// the way DELETE on that collection would be.
if (r.Method == "MOVE" || r.Method == "COPY") && req.destination != "" {
info, err := user.fs.Stat(r.Context(), req.destination)
if err == nil && info.IsDir() {
deletable := func(p Permissions) bool { return p.Delete }
// allowedThroughout reaches descendants only, and the destination
// check in Allowed covers the collection itself as an update rather
// than a delete, so that is checked here.
ok := user.allowedAt(req.destination, deletable)
if ok {
ok, err = user.fs.allowedThroughout(r.Context(), req.destination, deletable)
if err != nil {
lZap.Error("could not authorize destination subtree", zap.String("destination", req.destination), zap.Error(err))
w.WriteHeader(http.StatusForbidden)
return
}
}
if !ok {
lZap.Info("denied by a rule on the destination collection", zap.String("method", r.Method), zap.String("destination", req.destination))
w.WriteHeader(http.StatusForbidden)
return
}
}
}
if r.Method == "HEAD" {
w = responseWriterNoBody{w}
}
// Excerpt from RFC4918, section 9.4:
//
// GET, when applied to a collection, may return the contents of an
// "index.html" resource, a human-readable view of the contents of
// the collection, or something else altogether.
//
// Similarly, since the definition of HEAD is a GET without a response
// message body, the semantics of HEAD are unmodified when applied to
// collection resources.
//
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
if r.Method == "GET" || r.Method == "HEAD" {
info, err := user.fs.Stat(r.Context(), req.path)
if err == nil && info.IsDir() {
r.Method = "PROPFIND"
if r.Header.Get("Depth") == "" {
r.Header.Add("Depth", "1")
}
}
}
if r.Method == "OPTIONS" {
user.handleOptions(w, r, req.path)
return
}
if r.Method == "PATCH" || (r.Method == "PUT" && r.Header.Get("Content-Range") != "") {
user.handlePartialUpdate(w, r, req.path)
return
}
// Runs the WebDAV.
user.handler.ServeHTTP(w, r)
}
// getRequestLogger creates a zap.Logger using the request remote ip.
func getRequestLogger(r *http.Request, behindProxy bool) *zap.Logger {
// Retrieve the real client IP address using the updated helper function
remoteAddr := getRealRemoteIP(r, behindProxy)
return zap.L().With(zap.String("remote_address", remoteAddr))
}
// getRealRemoteIP retrieves the client's actual IP address, considering reverse proxies.
func getRealRemoteIP(r *http.Request, behindProxy bool) string {
if behindProxy {
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
return ip
}
}
return r.RemoteAddr
}
type responseWriterNoBody struct {
http.ResponseWriter
}
func (w responseWriterNoBody) Write(data []byte) (int, error) {
return len(data), nil
}
+1763
View File
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
package lib
import (
"path"
"path/filepath"
"time"
"golang.org/x/net/webdav"
)
var _ webdav.LockSystem = &lockSystem{}
// lockSystem wraps a [webdav.LockSystem], mapping virtual request names to the
// real backing paths via resolve. This allows reusing the same
// [webdav.LockSystem] for multiple users with different base directories,
// meaning we can correctly lock the files across different users.
type lockSystem struct {
webdav.LockSystem
resolve func(name string) (string, error)
}
// newLockSystem returns a lockSystem for a single-directory user, resolving
// names relative to directory.
func newLockSystem(ls webdav.LockSystem, directory string) *lockSystem {
return &lockSystem{
LockSystem: ls,
resolve: func(name string) (string, error) {
// Lock names share a slash-separated namespace across users, even
// on Windows where filepath.Join would emit backslashes and break
// descendant-lock matching in the underlying LockSystem.
return path.Join(filepath.ToSlash(directory), name), nil
},
}
}
// newMultiDirLockSystem returns a lockSystem for a multi-directory user,
// resolving names against the real backing path of each mount.
func newMultiDirLockSystem(ls webdav.LockSystem, mounts DirectoryMounts) *lockSystem {
return &lockSystem{
LockSystem: ls,
resolve: func(name string) (string, error) {
if cleanName(name) == "/" {
return "/", nil
}
mount, rest, err := multiDir{mounts: mounts}.resolve(name)
if err != nil {
return "", err
}
// filePath returns an OS-native path for real file operations; the
// lock namespace must stay slash-separated so descendant locks match
// on Windows.
return filepath.ToSlash(mount.filePath(rest)), nil
},
}
}
func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) {
if name0 != "" {
name0, err = l.resolve(name0)
if err != nil {
return nil, err
}
}
if name1 != "" {
name1, err = l.resolve(name1)
if err != nil {
return nil, err
}
}
return l.LockSystem.Confirm(now, name0, name1, conditions...)
}
func (l *lockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
details.Root, err = l.resolve(details.Root)
if err != nil {
return "", err
}
return l.LockSystem.Create(now, details)
}
+79
View File
@@ -0,0 +1,79 @@
package lib
import (
"path"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/net/webdav"
)
func TestLockSystemRootLockProtectsDescendants(t *testing.T) {
t.Parallel()
locks := newLockSystem(webdav.NewMemLS(), filepath.Join(t.TempDir(), "nested"))
now := time.Now()
token, err := locks.Create(now, webdav.LockDetails{
Root: "/",
Duration: time.Minute,
})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, locks.Unlock(time.Now(), token))
})
_, err = locks.Create(now, webdav.LockDetails{
Root: "/child.txt",
Duration: time.Minute,
ZeroDepth: true,
})
require.ErrorIs(t, err, webdav.ErrLocked)
}
func TestLockSystemSharesLocksAcrossNestedUserDirectories(t *testing.T) {
t.Parallel()
shared := webdav.NewMemLS()
parentDirectory := t.TempDir()
childDirectory := filepath.Join(parentDirectory, "child")
parent := newLockSystem(shared, parentDirectory)
child := newLockSystem(shared, childDirectory)
now := time.Now()
token, err := parent.Create(now, webdav.LockDetails{
Root: "/",
Duration: time.Minute,
})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, parent.Unlock(time.Now(), token))
})
_, err = child.Create(now, webdav.LockDetails{
Root: "/file.txt",
Duration: time.Minute,
ZeroDepth: true,
})
require.ErrorIs(t, err, webdav.ErrLocked)
// The lock key is slash-separated on every platform, so the child's file
// nests under the parent's root lock rather than diverging on Windows.
key, err := child.resolve("/file.txt")
require.NoError(t, err)
require.Equal(t, path.Join(filepath.ToSlash(childDirectory), "file.txt"), key)
}
func TestMultiDirLockSystemUsesSlashSeparatedKeys(t *testing.T) {
t.Parallel()
mounts := DirectoryMounts{{Name: "docs", Path: filepath.Join(t.TempDir(), "docs")}}
locks := newMultiDirLockSystem(webdav.NewMemLS(), mounts)
key, err := locks.resolve("/docs/report.txt")
require.NoError(t, err)
require.Equal(t, path.Join(filepath.ToSlash(mounts[0].Path), "report.txt"), key)
require.NotContains(t, key, "\\")
}
+384
View File
@@ -0,0 +1,384 @@
package lib
import (
"context"
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"runtime"
"sort"
"strings"
"syscall"
"time"
"golang.org/x/net/webdav"
)
var _ webdav.FileSystem = multiDir{}
const windowsErrorNotSameDevice = syscall.Errno(17)
type multiDir struct {
mounts DirectoryMounts
noSniff bool
}
func (m multiDir) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
mount, rest, err := m.resolve(name)
if err != nil {
return err
}
if rest == "/" {
return os.ErrExist
}
return mount.dir(m.noSniff).Mkdir(ctx, rest, perm)
}
func (m multiDir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if cleanName(name) == "/" {
if writeFlag(flag) {
return nil, os.ErrPermission
}
return &multiDirRootFile{
entries: m.rootEntries(ctx),
info: virtualDirInfo{name: "/"},
}, nil
}
mount, rest, err := m.resolve(name)
if err != nil {
return nil, err
}
if rest == "/" && writeFlag(flag) {
return nil, os.ErrPermission
}
file, err := mount.dir(m.noSniff).OpenFile(ctx, rest, flag, perm)
if err != nil {
return nil, err
}
if rest == "/" {
return mountRootFile{File: file, name: mount.Name}, nil
}
return file, nil
}
func (m multiDir) RemoveAll(ctx context.Context, name string) error {
if cleanName(name) == "/" {
return os.ErrInvalid
}
mount, rest, err := m.resolve(name)
if err != nil {
return err
}
if rest == "/" {
return os.ErrInvalid
}
return mount.dir(m.noSniff).RemoveAll(ctx, rest)
}
func (m multiDir) Rename(ctx context.Context, oldName, newName string) error {
oldMount, oldRest, err := m.resolve(oldName)
if err != nil {
return err
}
newMount, newRest, err := m.resolve(newName)
if err != nil {
return err
}
if oldRest == "/" || newRest == "/" {
return os.ErrInvalid
}
if oldMount.Name == newMount.Name {
return oldMount.dir(m.noSniff).Rename(ctx, oldRest, newRest)
}
oldPath := oldMount.filePath(oldRest)
newPath := newMount.filePath(newRest)
if err := os.Rename(oldPath, newPath); err != nil {
if isCrossDeviceError(err) {
return renameAcrossMount(oldPath, newPath)
}
return err
}
return nil
}
func renameAcrossMount(oldPath, newPath string) error {
info, err := os.Lstat(oldPath)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(oldPath)
if err != nil {
return err
}
if err := os.Symlink(target, newPath); err != nil {
return err
}
return os.Remove(oldPath)
}
if info.Mode().IsRegular() {
if err := copyRegularFile(oldPath, newPath); err != nil {
return err
}
} else {
source, err := fs.Sub(os.DirFS(filepath.Dir(oldPath)), filepath.Base(oldPath))
if err != nil {
return err
}
if err := os.CopyFS(newPath, source); err != nil {
_ = os.RemoveAll(newPath)
return err
}
}
if err := copyMetadata(oldPath, newPath); err != nil {
_ = os.RemoveAll(newPath)
return err
}
return os.RemoveAll(oldPath)
}
func copyRegularFile(oldPath, newPath string) error {
source, err := os.Open(oldPath)
if err != nil {
return err
}
target, err := os.OpenFile(newPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
_ = source.Close()
return err
}
_, copyErr := io.Copy(target, source)
copyErr = errors.Join(copyErr, target.Close(), source.Close())
if copyErr != nil {
_ = os.Remove(newPath)
return copyErr
}
return nil
}
func copyMetadata(oldPath, newPath string) error {
return filepath.Walk(oldPath, func(name string, info os.FileInfo, err error) error {
if err != nil || info.Mode()&os.ModeSymlink != 0 {
return err
}
rel, err := filepath.Rel(oldPath, name)
if err != nil {
return err
}
newName := filepath.Join(newPath, rel)
if err := os.Chmod(newName, info.Mode().Perm()); err != nil {
return err
}
return os.Chtimes(newName, info.ModTime(), info.ModTime())
})
}
func isCrossDeviceError(err error) bool {
return errors.Is(err, syscall.EXDEV) || runtime.GOOS == "windows" && errors.Is(err, windowsErrorNotSameDevice)
}
func (m multiDir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
if cleanName(name) == "/" {
return virtualDirInfo{name: "/"}, nil
}
mount, rest, err := m.resolve(name)
if err != nil {
return nil, err
}
info, err := mount.dir(m.noSniff).Stat(ctx, rest)
if err != nil {
return nil, err
}
if rest == "/" {
return namedFileInfo{FileInfo: info, name: mount.Name}, nil
}
return info, nil
}
func (m multiDir) resolve(name string) (DirectoryMount, string, error) {
name = cleanName(name)
if name == "/" {
return DirectoryMount{}, "", os.ErrInvalid
}
trimmed := strings.TrimPrefix(name, "/")
mountName, rest, _ := strings.Cut(trimmed, "/")
for _, mount := range m.mounts {
if mount.Name == mountName {
if rest == "" {
return mount, "/", nil
}
return mount, "/" + rest, nil
}
}
return DirectoryMount{}, "", os.ErrNotExist
}
func (m multiDir) rootEntries(ctx context.Context) []os.FileInfo {
entries := make([]os.FileInfo, 0, len(m.mounts))
for _, mount := range m.mounts {
info, err := mount.dir(m.noSniff).Stat(ctx, "/")
if err != nil {
entries = append(entries, virtualDirInfo{name: mount.Name})
continue
}
entries = append(entries, namedFileInfo{FileInfo: info, name: mount.Name})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
return entries
}
func (d DirectoryMount) dir(noSniff bool) Dir {
return Dir{
Dir: webdav.Dir(d.Path),
noSniff: noSniff,
}
}
func (d DirectoryMount) filePath(name string) string {
return filepath.Join(d.Path, filepath.FromSlash(strings.TrimPrefix(name, "/")))
}
func cleanName(name string) string {
if name == "" || !strings.HasPrefix(name, "/") {
name = "/" + name
}
return path.Clean(name)
}
func writeFlag(flag int) bool {
return flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_TRUNC|os.O_APPEND) != 0
}
type multiDirRootFile struct {
entries []os.FileInfo
info os.FileInfo
offset int
}
func (f *multiDirRootFile) Close() error {
return nil
}
func (f *multiDirRootFile) Read([]byte) (int, error) {
return 0, io.EOF
}
func (f *multiDirRootFile) Seek(offset int64, whence int) (int64, error) {
var next int64
switch whence {
case io.SeekStart:
next = offset
case io.SeekCurrent:
next = int64(f.offset) + offset
case io.SeekEnd:
next = int64(len(f.entries)) + offset
default:
return 0, os.ErrInvalid
}
if next < 0 {
return 0, os.ErrInvalid
}
f.offset = int(next)
return next, nil
}
func (f *multiDirRootFile) Readdir(count int) ([]os.FileInfo, error) {
if count <= 0 {
entries := f.entries[f.offset:]
f.offset = len(f.entries)
return entries, nil
}
if f.offset >= len(f.entries) {
return nil, io.EOF
}
end := f.offset + count
if end > len(f.entries) {
end = len(f.entries)
}
entries := f.entries[f.offset:end]
f.offset = end
return entries, nil
}
func (f *multiDirRootFile) Stat() (os.FileInfo, error) {
return f.info, nil
}
func (f *multiDirRootFile) Write([]byte) (int, error) {
return 0, os.ErrPermission
}
type mountRootFile struct {
webdav.File
name string
}
func (f mountRootFile) Stat() (os.FileInfo, error) {
info, err := f.File.Stat()
if err != nil {
return nil, err
}
return namedFileInfo{FileInfo: info, name: f.name}, nil
}
type namedFileInfo struct {
os.FileInfo
name string
}
func (i namedFileInfo) Name() string {
return i.name
}
type virtualDirInfo struct {
name string
}
func (i virtualDirInfo) Name() string {
return i.name
}
func (i virtualDirInfo) Size() int64 {
return 0
}
func (i virtualDirInfo) Mode() os.FileMode {
return os.ModeDir | 0555
}
func (i virtualDirInfo) ModTime() time.Time {
return time.Time{}
}
func (i virtualDirInfo) IsDir() bool {
return true
}
func (i virtualDirInfo) Sys() any {
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package lib
import (
"os"
"path/filepath"
"runtime"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRenameAcrossMount(t *testing.T) {
source := makeTestDirectory(t, map[string][]byte{
"file.txt": []byte("cross mount"),
"folder/empty": nil,
"folder/nested/file.txt": []byte("nested"),
})
target := t.TempDir()
sourceFile := filepath.Join(source, "file.txt")
modTime := time.Date(2020, time.January, 2, 3, 4, 5, 0, time.UTC)
require.NoError(t, os.Chmod(sourceFile, 0600))
require.NoError(t, os.Chtimes(sourceFile, modTime, modTime))
require.NoError(t, renameAcrossMount(sourceFile, filepath.Join(target, "file.txt")))
require.NoFileExists(t, filepath.Join(source, "file.txt"))
data, err := os.ReadFile(filepath.Join(target, "file.txt"))
require.NoError(t, err)
require.Equal(t, []byte("cross mount"), data)
info, err := os.Stat(filepath.Join(target, "file.txt"))
require.NoError(t, err)
if runtime.GOOS != "windows" {
require.Equal(t, os.FileMode(0600), info.Mode().Perm())
}
require.WithinDuration(t, modTime, info.ModTime(), time.Second)
require.NoError(t, renameAcrossMount(filepath.Join(source, "folder"), filepath.Join(target, "folder")))
require.NoDirExists(t, filepath.Join(source, "folder"))
require.DirExists(t, filepath.Join(target, "folder", "empty"))
data, err = os.ReadFile(filepath.Join(target, "folder", "nested", "file.txt"))
require.NoError(t, err)
require.Equal(t, []byte("nested"), data)
}
func TestRenameAcrossMountPreservesSymlink(t *testing.T) {
source := makeTestDirectory(t, map[string][]byte{
"file.txt": []byte("target"),
})
oldPath := filepath.Join(source, "link.txt")
if err := os.Symlink("file.txt", oldPath); err != nil {
t.Skipf("symbolic links are unavailable: %v", err)
}
newPath := filepath.Join(t.TempDir(), "link.txt")
require.NoError(t, renameAcrossMount(oldPath, newPath))
info, err := os.Lstat(newPath)
require.NoError(t, err)
require.NotZero(t, info.Mode()&os.ModeSymlink)
target, err := os.Readlink(newPath)
require.NoError(t, err)
require.Equal(t, "file.txt", target)
}
func TestIsCrossDeviceError(t *testing.T) {
err := syscall.EXDEV
if runtime.GOOS == "windows" {
err = windowsErrorNotSameDevice
}
require.True(t, isCrossDeviceError(err))
require.False(t, isCrossDeviceError(os.ErrPermission))
}
+526
View File
@@ -0,0 +1,526 @@
package lib
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"golang.org/x/net/webdav"
)
const partialUpdateContentType = "application/x-sabredav-partialupdate"
type updateRange struct {
offset int64
end int64
hasEnd bool
append bool
}
type partialUpdateError struct {
status int
err error
}
func (e partialUpdateError) Error() string {
return e.err.Error()
}
func newPartialUpdateError(status int, message string) error {
return partialUpdateError{status: status, err: errors.New(message)}
}
func writePartialUpdateError(w http.ResponseWriter, err error, fallbackStatus int) {
var httpErr partialUpdateError
if errors.As(err, &httpErr) {
fallbackStatus = httpErr.status
}
http.Error(w, err.Error(), fallbackStatus)
}
func (u *handlerUser) handleOptions(w http.ResponseWriter, r *http.Request, reqPath string) {
allow := "OPTIONS, LOCK, PUT, MKCOL, PATCH"
if fi, err := u.fs.Stat(r.Context(), reqPath); err == nil {
if fi.IsDir() {
allow = "OPTIONS, LOCK, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND"
} else {
allow = "OPTIONS, LOCK, GET, HEAD, POST, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND, PUT, PATCH"
}
}
w.Header().Set("Allow", allow)
w.Header().Set("DAV", "1, 2, sabredav-partialupdate")
w.Header().Set("MS-Author-Via", "DAV")
w.Header().Set("Accept-Patch", partialUpdateContentType)
w.WriteHeader(http.StatusOK)
}
func (u *handlerUser) handlePartialUpdate(w http.ResponseWriter, r *http.Request, reqPath string) {
contentRange := r.Header.Get("Content-Range")
isContentRangePut := r.Method == "PUT" && contentRange != ""
var (
updateRange updateRange
err error
)
if isContentRangePut {
updateRange, err = parseContentRange(contentRange)
} else {
if err := checkPartialUpdateContentType(r.Header.Get("Content-Type")); err != nil {
http.Error(w, err.Error(), http.StatusUnsupportedMediaType)
return
}
updateRange, err = parseUpdateRange(r.Header.Get("X-Update-Range"))
}
if err != nil {
writePartialUpdateError(w, err, http.StatusBadRequest)
return
}
if r.Method == "PATCH" && r.ContentLength < 0 {
http.Error(w, "missing content length", http.StatusLengthRequired)
return
}
release, status, err := u.confirmPartialUpdateLocks(r, reqPath)
if err != nil {
http.Error(w, err.Error(), status)
return
}
defer release()
ctx := r.Context()
fi, statErr := u.fs.Stat(ctx, reqPath)
exists := statErr == nil
if statErr != nil && !os.IsNotExist(statErr) {
http.Error(w, statErr.Error(), http.StatusMethodNotAllowed)
return
}
if exists && fi.IsDir() {
http.Error(w, "cannot update a collection", http.StatusMethodNotAllowed)
return
}
etag, status, err := u.checkPartialUpdatePreconditions(r, exists, fi)
if err != nil {
if etag != "" {
w.Header().Set("ETag", etag)
}
http.Error(w, err.Error(), status)
return
}
currentSize := int64(0)
if exists {
currentSize = fi.Size()
}
if updateRange.append {
updateRange.offset = currentSize
} else if updateRange.offset < 0 {
updateRange.offset += currentSize
if updateRange.offset < 0 {
updateRange.offset = 0
}
}
if updateRange.hasEnd {
expected := updateRange.end - updateRange.offset + 1
if expected < 0 {
http.Error(w, "invalid byte range", http.StatusRequestedRangeNotSatisfiable)
return
}
if r.ContentLength >= 0 && r.ContentLength != expected {
http.Error(w, "content length does not match byte range", http.StatusRequestedRangeNotSatisfiable)
return
}
}
body := io.Reader(r.Body)
var cleanup func()
if updateRange.hasEnd {
body, cleanup, err = spoolBoundedBody(r.Body, updateRange.end-updateRange.offset+1)
if err != nil {
writePartialUpdateError(w, err, http.StatusMethodNotAllowed)
return
}
defer cleanup()
}
flag := os.O_RDWR
if !exists {
flag |= os.O_CREATE
}
f, err := u.fs.OpenFile(ctx, reqPath, flag, 0666)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
http.Error(w, err.Error(), http.StatusNotFound)
return
}
defer func() { _ = f.Close() }()
if _, err := f.Seek(updateRange.offset, io.SeekStart); err != nil {
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
return
}
if _, err := io.Copy(f, body); err != nil {
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
return
}
if !exists {
w.WriteHeader(http.StatusCreated)
return
}
w.WriteHeader(http.StatusNoContent)
}
func checkPartialUpdateContentType(contentType string) error {
if contentType == "" {
return errors.New("missing content type")
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return err
}
if mediaType != partialUpdateContentType {
return fmt.Errorf("unsupported content type %q", mediaType)
}
return nil
}
func (u *handlerUser) checkPartialUpdatePreconditions(r *http.Request, exists bool, fi os.FileInfo) (etag string, status int, err error) {
ifMatch := r.Header.Get("If-Match")
ifNoneMatch := r.Header.Get("If-None-Match")
if ifMatch == "" && ifNoneMatch == "" {
return "", 0, nil
}
if ifMatch != "" && !exists {
return "", http.StatusPreconditionFailed, errors.New("resource does not exist")
}
if exists {
etag, err = findPartialETag(r.Context(), fi)
if err != nil {
return "", http.StatusInternalServerError, err
}
}
if ifMatch != "" && !partialETagHeaderMatches(ifMatch, etag, exists) {
return etag, http.StatusPreconditionFailed, errors.New("etag does not match")
}
if ifNoneMatch != "" && exists && partialETagHeaderMatches(ifNoneMatch, etag, true) {
return etag, http.StatusPreconditionFailed, errors.New("etag matches")
}
return etag, 0, nil
}
func findPartialETag(ctx context.Context, fi os.FileInfo) (string, error) {
if etager, ok := fi.(webdav.ETager); ok {
etag, err := etager.ETag(ctx)
if !errors.Is(err, webdav.ErrNotImplemented) {
return etag, err
}
}
return fmt.Sprintf(`"%x%x"`, fi.ModTime().UnixNano(), fi.Size()), nil
}
func partialETagHeaderMatches(header, etag string, exists bool) bool {
for _, item := range strings.Split(header, ",") {
item = strings.TrimSpace(item)
if item == "*" {
return exists
}
if item == etag || strings.ReplaceAll(item, `\"`, `"`) == etag {
return true
}
}
return false
}
func parseUpdateRange(header string) (updateRange, error) {
if header == "" {
return updateRange{}, errors.New("missing X-Update-Range header")
}
if header == "append" {
return updateRange{append: true}, nil
}
if !strings.HasPrefix(header, "bytes=") {
return updateRange{}, errors.New("invalid X-Update-Range header")
}
return parseByteRange(strings.TrimPrefix(header, "bytes="), true)
}
func parseContentRange(header string) (updateRange, error) {
if !strings.HasPrefix(header, "bytes ") {
return updateRange{}, errors.New("invalid Content-Range header")
}
spec, _, ok := strings.Cut(strings.TrimPrefix(header, "bytes "), "/")
if !ok {
return updateRange{}, errors.New("invalid Content-Range header")
}
return parseByteRange(spec, false)
}
func parseByteRange(spec string, allowNegativeStart bool) (updateRange, error) {
if strings.HasPrefix(spec, "-") {
if !allowNegativeStart {
return updateRange{}, errors.New("invalid byte range start")
}
start, err := strconv.ParseInt(strings.TrimPrefix(spec, "-"), 10, 64)
if err != nil || start < 0 {
return updateRange{}, errors.New("invalid byte range start")
}
if start == 0 {
return updateRange{append: true}, nil
}
return updateRange{offset: -start}, nil
}
startText, endText, ok := strings.Cut(spec, "-")
if !ok || startText == "" {
return updateRange{}, errors.New("invalid byte range")
}
start, err := strconv.ParseInt(startText, 10, 64)
if err != nil {
return updateRange{}, errors.New("invalid byte range start")
}
if start < 0 && !allowNegativeStart {
return updateRange{}, errors.New("invalid byte range start")
}
r := updateRange{offset: start}
if endText == "" {
return r, nil
}
if start < 0 {
return updateRange{}, errors.New("negative byte range cannot include an end")
}
end, err := strconv.ParseInt(endText, 10, 64)
if err != nil {
return updateRange{}, errors.New("invalid byte range end")
}
if end < start {
return updateRange{}, newPartialUpdateError(http.StatusRequestedRangeNotSatisfiable, "invalid byte range")
}
r.end = end
r.hasEnd = true
return r, nil
}
func spoolBoundedBody(body io.Reader, expected int64) (io.Reader, func(), error) {
tmp, err := os.CreateTemp("", "webdav-partial-update-*")
if err != nil {
return nil, nil, err
}
cleanup := func() {
name := tmp.Name()
_ = tmp.Close()
_ = os.Remove(name)
}
cleanupOnError := true
defer func() {
if cleanupOnError {
cleanup()
}
}()
n, err := io.Copy(tmp, io.LimitReader(body, expected+1))
if err != nil {
return nil, nil, err
}
if n != expected {
return nil, nil, newPartialUpdateError(http.StatusRequestedRangeNotSatisfiable, "body length does not match byte range")
}
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return nil, nil, err
}
cleanupOnError = false
return tmp, cleanup, nil
}
// confirmPartialUpdateLocks mirrors the unexported confirmLocks helper from
// golang.org/x/net/webdav so that partial updates honor WebDAV locks the same
// way regular PUT requests do. Keep it in sync if the upstream behavior changes.
func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (release func(), status int, err error) {
hdr := r.Header.Get("If")
if hdr == "" {
now := time.Now()
token, err := u.handler.LockSystem.Create(now, webdav.LockDetails{
Root: src,
Duration: -1,
ZeroDepth: true,
})
if err != nil {
if errors.Is(err, webdav.ErrLocked) {
return nil, webdav.StatusLocked, err
}
return nil, http.StatusInternalServerError, err
}
return func() {
_ = u.handler.LockSystem.Unlock(now, token)
}, 0, nil
}
ifLists, ok := parsePartialIfHeader(hdr)
if !ok {
return nil, http.StatusBadRequest, errors.New("webdav: invalid If header")
}
for _, l := range ifLists {
lsrc := l.resourceTag
if lsrc == "" {
lsrc = src
} else {
parsedURL, err := url.Parse(lsrc)
if err != nil {
continue
}
if parsedURL.Host != r.Host {
continue
}
lsrc, err = stripPartialPrefix(parsedURL.Path, u.handler.Prefix)
if err != nil {
return nil, http.StatusNotFound, err
}
if lsrc == "" {
lsrc = src
}
}
release, err = u.handler.LockSystem.Confirm(time.Now(), lsrc, "", l.conditions...)
if errors.Is(err, webdav.ErrConfirmationFailed) {
continue
}
if err != nil {
return nil, http.StatusInternalServerError, err
}
return release, 0, nil
}
return nil, http.StatusPreconditionFailed, webdav.ErrLocked
}
type partialIfList struct {
resourceTag string
conditions []webdav.Condition
}
// parsePartialIfHeader, parsePartialIfConditions and cutPartialIfToken
// reimplement the unexported If-header parser from golang.org/x/net/webdav,
// which is not accessible from outside that package. Keep them in sync with the
// upstream parseIfHeader if it changes.
func parsePartialIfHeader(header string) ([]partialIfList, bool) {
s := strings.TrimSpace(header)
tagged := strings.HasPrefix(s, "<")
var lists []partialIfList
for s != "" {
resourceTag := ""
if strings.HasPrefix(s, "<") {
if !tagged {
return nil, false
}
var ok bool
resourceTag, s, ok = cutPartialIfToken(s, '<', '>')
if !ok {
return nil, false
}
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "(") {
return nil, false
}
}
for strings.HasPrefix(s, "(") {
body, rest, ok := cutPartialIfToken(s, '(', ')')
if !ok {
return nil, false
}
conditions, ok := parsePartialIfConditions(body)
if !ok {
return nil, false
}
lists = append(lists, partialIfList{resourceTag: resourceTag, conditions: conditions})
s = strings.TrimSpace(rest)
}
if s != "" && !strings.HasPrefix(s, "<") {
return nil, false
}
}
return lists, len(lists) > 0
}
func parsePartialIfConditions(s string) ([]webdav.Condition, bool) {
var conditions []webdav.Condition
for {
s = strings.TrimSpace(s)
if s == "" {
return conditions, len(conditions) > 0
}
not := false
if strings.HasPrefix(s, "Not ") || strings.HasPrefix(s, "Not\t") {
not = true
s = strings.TrimSpace(s[3:])
}
if s == "" {
return nil, false
}
var token string
switch s[0] {
case '<':
var ok bool
token, s, ok = cutPartialIfToken(s, '<', '>')
if !ok {
return nil, false
}
conditions = append(conditions, webdav.Condition{Not: not, Token: token})
case '[':
var ok bool
token, s, ok = cutPartialIfToken(s, '[', ']')
if !ok {
return nil, false
}
conditions = append(conditions, webdav.Condition{Not: not, ETag: token})
default:
i := strings.IndexAny(s, " \t")
if i < 0 {
token, s = s, ""
} else {
token, s = s[:i], s[i:]
}
if token == "" || strings.ContainsAny(token, "()<>[]") {
return nil, false
}
conditions = append(conditions, webdav.Condition{Not: not, Token: token})
}
}
}
func cutPartialIfToken(s string, open, close byte) (string, string, bool) {
if s == "" || s[0] != open {
return "", "", false
}
token, rest, ok := strings.Cut(s[1:], string(close))
return token, rest, ok
}
func stripPartialPrefix(p, prefix string) (string, error) {
if prefix == "" {
return p, nil
}
if stripped := strings.TrimPrefix(p, prefix); len(stripped) < len(p) {
return stripped, nil
}
return "", errors.New("webdav: prefix mismatch")
}
+323
View File
@@ -0,0 +1,323 @@
package lib
import (
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
)
type Rule struct {
Permissions Permissions
Path string
Regex *regexp.Regexp
}
func (r *Rule) Validate() error {
if r.Regex == nil && r.Path == "" {
return errors.New("invalid rule: must either define a path of a regex")
}
if r.Regex != nil && r.Path != "" {
return errors.New("invalid rule: cannot define both regex and path")
}
return nil
}
// Matches checks if [Rule] matches the given path. When caseInsensitive is set
// the backing file system ignores case, so this must too. A regex is tried
// against the folded path as well as the path as written, which only widens it
// to spellings naming the same file.
func (r *Rule) Matches(path string, caseInsensitive bool) bool {
if r.Regex != nil {
if caseInsensitive {
return r.Regex.MatchString(path) || r.Regex.MatchString(foldPath(path))
}
return r.Regex.MatchString(path)
}
if caseInsensitive {
return strings.HasPrefix(foldPath(path), foldPath(r.Path))
}
return strings.HasPrefix(path, r.Path)
}
// matchesCollection checks if [Rule] names path as the collection it governs,
// such as a rule for "/c/" and a request for "/c". Regex rules are matched
// literally and are not considered here.
func (r *Rule) matchesCollection(path string, caseInsensitive bool) bool {
if r.Regex != nil || !strings.HasSuffix(r.Path, "/") {
return false
}
if caseInsensitive {
return foldPath(path) == foldPath(strings.TrimSuffix(r.Path, "/"))
}
return path == strings.TrimSuffix(r.Path, "/")
}
type RulesBehavior string
const (
RulesOverwrite RulesBehavior = "overwrite"
RulesAppend RulesBehavior = "append"
)
type UserPermissions struct {
Directory string
Directories DirectoryMounts
Permissions Permissions
Rules []*Rule
RulesBehavior RulesBehavior
directoryExplicit bool
directoriesExplicit bool
useDirectories bool
caseInsensitive bool
}
type DirectoryMount struct {
Name string
Path string
}
type DirectoryMounts []DirectoryMount
// Allowed checks if the user has permission to access a directory/file
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
// For COPY and MOVE requests, we first check the permissions for the destination
// path. As soon as a rule matches and does not allow the operation at the destination,
// we fail immediately. If no rule matches, we check the global permissions.
if r.method == "COPY" || r.method == "MOVE" {
if !p.allowedAt(r.destination, func(perms Permissions) bool {
return perms.AllowedDestination(r, fileExists)
}) {
return false
}
}
return p.allowedAt(r.path, func(perms Permissions) bool {
return perms.Allowed(r, fileExists)
})
}
// allowedAt resolves the permissions that govern path and applies check to them.
func (p UserPermissions) allowedAt(path string, check func(Permissions) bool) bool {
// Go through rules beginning from the last one. The first matched rule returns.
// Both senses of matching are tested per rule: in separate passes a broader
// rule would return first and shadow the narrower one naming the collection.
for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].Matches(path, p.caseInsensitive) {
return check(p.Rules[i].Permissions)
}
// A rule written with a trailing slash also governs the collection it names,
// so a rule for "/c/" cannot be evaded by asking for "/c". Such a request acts
// on an entry of the parent collection, so it needs those permissions too: the
// rule can restrict the collection, not grant access that would not exist.
if p.Rules[i].matchesCollection(path, p.caseInsensitive) {
if !check(p.Rules[i].Permissions) {
return false
}
// Through the rules, not the global permissions alone, which would deny
// a collection that an enclosing rule grants.
if parent := parentCollection(path); parent != path {
return p.allowedAt(parent, check)
}
return check(p.Permissions)
}
}
return check(p.Permissions)
}
// parentCollection returns the collection containing path, such as "/data/" for
// "/data/sub", or "/" for a top-level entry. That bounds allowedAt at the root.
func parentCollection(p string) string {
i := strings.LastIndex(strings.TrimSuffix(p, "/"), "/")
if i <= 0 {
return "/"
}
return p[:i+1]
}
func (p *UserPermissions) Validate() error {
var err error
p.Directory, err = filepath.Abs(p.Directory)
if err != nil {
return fmt.Errorf("invalid permissions: %w", err)
}
if p.useDirectories || len(p.Directories) > 0 {
if err := (&p.Directories).Validate(); err != nil {
return fmt.Errorf("invalid permissions: %w", err)
}
}
p.caseInsensitive = p.hasCaseInsensitiveBacking()
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
}
// hasCaseInsensitiveBacking reports whether any backing directory resolves names
// regardless of case. Mounts spread over volumes that differ all fold, which
// keeps deny rules effective on the case-insensitive ones.
func (p *UserPermissions) hasCaseInsensitiveBacking() bool {
if p.useDirectories || len(p.Directories) > 0 {
for _, mount := range p.Directories {
if caseInsensitiveFS(mount.Path) {
return true
}
}
return false
}
return caseInsensitiveFS(p.Directory)
}
func (d *DirectoryMounts) Validate() error {
names := map[string]struct{}{}
for i := range *d {
mount := &(*d)[i]
if mount.Path == "" {
return errors.New("invalid directories: path must be defined")
}
path, err := filepath.Abs(mount.Path)
if err != nil {
return fmt.Errorf("invalid directories: %w", err)
}
mount.Path = path
if mount.Name == "" {
mount.Name = filepath.Base(path)
}
if !validDirectoryMountName(mount.Name) {
return fmt.Errorf("invalid directories: invalid mount name %q", mount.Name)
}
if _, ok := names[mount.Name]; ok {
return fmt.Errorf("invalid directories: duplicate mount name %q", mount.Name)
}
names[mount.Name] = struct{}{}
}
return nil
}
func validDirectoryMountName(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
return !strings.ContainsAny(name, `/\`)
}
type Permissions struct {
Create bool
Read bool
Update bool
Delete bool
}
func (p *Permissions) UnmarshalText(data []byte) error {
text := strings.ToLower(string(data))
if text == "none" {
return nil
}
for _, c := range text {
switch c {
case 'c':
p.Create = true
case 'r':
p.Read = true
case 'u':
p.Update = true
case 'd':
p.Delete = true
default:
return fmt.Errorf("invalid permission: %q", c)
}
}
return nil
}
// Allowed returns whether this permission set has permissions to execute this
// request in the source directory. This applies to all requests with all methods.
func (p Permissions) Allowed(r *request, fileExists func(string) bool) bool {
switch r.method {
case "GET", "HEAD", "OPTIONS", "POST", "PROPFIND":
// Note: POST backend implementation just returns the same thing as GET.
return p.Read
case "MKCOL":
return p.Create
case "PROPPATCH":
return p.Update
case "PUT", "PATCH":
if fileExists(r.path) {
return p.Update
} else {
return p.Create
}
case "COPY":
return p.Read
case "MOVE":
return p.Read && p.Delete
case "DELETE":
return p.Delete
case "LOCK":
// A lock is write-class: it reserves the resource against other writers,
// and locking a path that does not exist creates it.
if fileExists(r.path) {
return p.Update
} else {
return p.Create
}
case "UNLOCK":
return p.Create || p.Update
default:
return false
}
}
// AllowedDestination returns whether this permissions set has permissions to execute this
// request in the destination directory. This only applies for COPY and MOVE requests.
func (p Permissions) AllowedDestination(r *request, fileExists func(string) bool) bool {
switch r.method {
case "COPY", "MOVE":
if fileExists(r.destination) {
return p.Update
} else {
return p.Create
}
default:
return false
}
}
+158
View File
@@ -0,0 +1,158 @@
package lib
import (
"context"
"errors"
"io"
"os"
"path"
"golang.org/x/net/webdav"
)
var _ webdav.FileSystem = permissionsFS{}
// permissionsFS wraps a [webdav.FileSystem] so directory listings only report
// entries the user may read. PROPFIND and COPY reach descendants by enumerating
// through the file system, so rules have to be applied as those listings are produced.
//
// The wrapped file system is a named field rather than an embedded one on
// purpose. allowedThroughout has to read unfiltered listings, and a promoted
// OpenFile would make walking the filtered view by accident a one-character
// change that authorizes everything without failing any obvious way.
type permissionsFS struct {
fs webdav.FileSystem
perms UserPermissions
}
func (f permissionsFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
return f.fs.Mkdir(ctx, name, perm)
}
func (f permissionsFS) RemoveAll(ctx context.Context, name string) error {
return f.fs.RemoveAll(ctx, name)
}
func (f permissionsFS) Rename(ctx context.Context, oldName, newName string) error {
return f.fs.Rename(ctx, oldName, newName)
}
func (f permissionsFS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
return f.fs.Stat(ctx, name)
}
func (f permissionsFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
file, err := f.fs.OpenFile(ctx, name, flag, perm)
if err != nil {
return nil, err
}
// The handler strips its prefix first, which for the default prefix "/" also
// drops the leading slash. Rules are written with one.
return &permissionsFile{File: file, name: cleanPath(name), perms: f.perms}, nil
}
type permissionsFile struct {
webdav.File
name string
perms UserPermissions
}
func (f *permissionsFile) Readdir(count int) ([]os.FileInfo, error) {
if count <= 0 {
fis, err := f.File.Readdir(count)
if err != nil {
return nil, err
}
return f.readable(fis), nil
}
// A positive count asks for that many entries, so keep reading until that
// many survive filtering: a short read would look like the end of the listing.
var entries []os.FileInfo
for len(entries) < count {
fis, err := f.File.Readdir(count - len(entries))
if err != nil {
if len(entries) > 0 && errors.Is(err, io.EOF) {
return entries, nil
}
return nil, err
}
if len(fis) == 0 {
break
}
entries = append(entries, f.readable(fis)...)
}
return entries, nil
}
// readable returns the entries whose path the user is allowed to read.
func (f *permissionsFile) readable(fis []os.FileInfo) []os.FileInfo {
allowed := make([]os.FileInfo, 0, len(fis))
for _, fi := range fis {
if f.perms.allowedAt(path.Join(f.name, fi.Name()), func(p Permissions) bool {
return p.Read
}) {
allowed = append(allowed, fi)
}
}
return allowed
}
// allowedThroughout reports whether check holds for every descendant of name.
// Rename and RemoveAll act on a subtree in one call without consulting the file
// system per descendant, so MOVE and DELETE need this before dispatching.
//
// It reads f.fs directly rather than the [permissionsFS] view: the latter omits
// the very entries this needs to refuse on.
func (f permissionsFS) allowedThroughout(ctx context.Context, name string, check func(Permissions) bool) (bool, error) {
info, err := f.fs.Stat(ctx, name)
if err != nil {
if os.IsNotExist(err) {
// Nothing to walk; the request fails later on its own terms.
return true, nil
}
return false, err
}
if !info.IsDir() {
return true, nil
}
file, err := f.fs.OpenFile(ctx, name, os.O_RDONLY, 0)
if err != nil {
return false, err
}
entries, err := file.Readdir(-1)
err = errors.Join(err, file.Close())
if err != nil {
return false, err
}
for _, entry := range entries {
child := path.Join(name, entry.Name())
if !f.perms.allowedAt(child, check) {
return false, nil
}
if entry.IsDir() {
ok, err := f.allowedThroughout(ctx, child, check)
if !ok || err != nil {
return ok, err
}
}
}
return true, nil
}
+115
View File
@@ -0,0 +1,115 @@
package lib
import (
"path/filepath"
"regexp"
"testing"
"github.com/stretchr/testify/require"
)
func TestRuleMatchesFolding(t *testing.T) {
t.Parallel()
rule := &Rule{Path: "/pub/"}
// Exact comparison: on a case-sensitive file system "/PUB/" is a different
// directory, which a rule granting "/pub/" must not reach.
require.True(t, rule.Matches("/pub/x.txt", false))
require.False(t, rule.Matches("/PUB/x.txt", false))
require.False(t, rule.Matches("/Pub/x.txt", false))
// Folded they name one directory, so the rule governs both.
require.True(t, rule.Matches("/pub/x.txt", true))
require.True(t, rule.Matches("/PUB/x.txt", true))
require.True(t, rule.Matches("/Pub/x.txt", true))
// A sibling merely starting with the same characters stays unaffected.
require.False(t, rule.Matches("/public/x.txt", true))
}
func TestRuleMatchesNormalization(t *testing.T) {
t.Parallel()
const (
nfc = "/café/" // café, composed
nfd = "/café/" // café, decomposed
)
rule := &Rule{Path: nfc}
// A file system ignoring case treats these spellings as one file too, so
// folding has to normalize or the rule is evaded by retyping it.
require.True(t, rule.Matches(nfd+"flag.txt", true))
require.False(t, rule.Matches(nfd+"flag.txt", false))
}
func TestRuleMatchesCollectionFolding(t *testing.T) {
t.Parallel()
rule := &Rule{Path: "/c/"}
require.True(t, rule.matchesCollection("/c", false))
require.False(t, rule.matchesCollection("/C", false))
require.True(t, rule.matchesCollection("/C", true))
// A rule without a trailing slash names a resource, not a collection.
require.False(t, (&Rule{Path: "/c"}).matchesCollection("/c", false))
}
func TestParentCollection(t *testing.T) {
t.Parallel()
require.Equal(t, "/data/", parentCollection("/data/sub"))
require.Equal(t, "/data/", parentCollection("/data/sub/"))
require.Equal(t, "/data/sub/", parentCollection("/data/sub/leaf.txt"))
require.Equal(t, "/", parentCollection("/pub"))
require.Equal(t, "/", parentCollection("/"))
}
func TestFlipCase(t *testing.T) {
t.Parallel()
// flipCase builds a path to stat, so it is separated the way the running
// system separates paths, not the way request paths are.
srv := func(name string) string {
return filepath.Join("/srv", name)
}
alt, ok := flipCase(srv("dav"))
require.True(t, ok)
require.Equal(t, srv("Dav"), alt)
alt, ok = flipCase(srv("DAV"))
require.True(t, ok)
require.Equal(t, srv("dAV"), alt)
// Only the first letter flips, so a name whose case mapping does not
// round-trip is left alone rather than changed by more than its case.
alt, ok = flipCase(srv("ıstanbul"))
require.True(t, ok)
require.Equal(t, srv("ıStanbul"), alt)
// Nothing to flip.
_, ok = flipCase(srv("001"))
require.False(t, ok)
}
func TestRuleMatchesRegexFolding(t *testing.T) {
t.Parallel()
rule := &Rule{Regex: regexp.MustCompile("^/secret/")}
require.True(t, rule.Matches("/secret/flag.txt", false))
require.False(t, rule.Matches("/SECRET/flag.txt", false))
// Where the file system serves both spellings as one file, the rule has to
// cover both or it denies nothing.
require.True(t, rule.Matches("/SECRET/flag.txt", true))
// A pattern written with upper case still relies on the path as written, so
// folding never takes a match away.
upper := &Rule{Regex: regexp.MustCompile("^/Secret/")}
require.True(t, upper.Matches("/Secret/flag.txt", true))
require.False(t, upper.Matches("/public/flag.txt", true))
}
+84
View File
@@ -0,0 +1,84 @@
package lib
import (
"errors"
"net/http"
"net/url"
"path"
"strings"
)
// cleanPath resolves dot segments so that the permission checks see the same
// path that the backing file system will ultimately open. The file systems in
// golang.org/x/net/webdav apply path.Clean before joining the backing
// directory, so without this the two layers disagree on which file a request
// names and a rule can be bypassed with e.g. "/public/../secret/file.txt".
func cleanPath(p string) string {
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
cleaned := path.Clean(p)
// path.Clean drops the trailing slash, but rules are prefix matches and are
// commonly written with one, such as "/c/". Dropping it would stop a request
// for the collection itself from matching the rule that names it.
if cleaned != "/" && isCollectionPath(p) {
cleaned += "/"
}
return cleaned
}
// isCollectionPath reports whether p names a collection rather than a resource
// within it. Besides an explicit trailing slash, a trailing "." or ".." segment
// also resolves to the collection itself.
func isCollectionPath(p string) bool {
return strings.HasSuffix(p, "/") || strings.HasSuffix(p, "/.") || strings.HasSuffix(p, "/..")
}
type request struct {
method string
path string
destination string
}
func newRequest(r *http.Request, prefix string) (*request, error) {
ctx := &request{
method: r.Method,
}
if destination := r.Header.Get("Destination"); destination != "" {
u, err := url.Parse(destination)
if err != nil {
return nil, errors.New("invalid destination header")
}
// RFC 4918, section 10.3, has Destination as an absolute URI, which is
// what clients send in practice. Only the path is relevant here, and
// taking it unconditionally keeps the host out of the matched value.
destination = u.Path
if prefix != "" {
destination = strings.TrimPrefix(u.Path, prefix)
if len(destination) >= len(u.Path) {
return nil, errors.New("invalid url prefix")
}
}
ctx.destination = cleanPath(destination)
}
path := r.URL.Path
if prefix != "" {
path = strings.TrimPrefix(r.URL.Path, prefix)
if len(path) >= len(r.URL.Path) {
return nil, errors.New("invalid url prefix")
}
}
ctx.path = cleanPath(path)
return ctx, nil
}
+61
View 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
}
+2 -3
View File
@@ -1,12 +1,11 @@
package main
import (
"runtime"
"github.com/hacdias/webdav/v5/cmd"
"github.com/hacdias/webdav/cmd"
_ "golang.org/x/crypto/x509roots/fallback"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
cmd.Execute()
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
"group:allNonMajor",
"group:allDigest",
":disableDependencyDashboard",
":semanticCommitTypeAll(chore)"
],
"postUpdateOptions": [
"gomodUpdateImportPaths",
"gomodTidy"
],
"schedule": "* * * * 0,6"
}
-12
View File
@@ -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
-48
View File
@@ -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
}
-25
View File
@@ -1,25 +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
}
func isAllowedHost(allowedHosts []string, origin string) bool {
for _, host := range allowedHosts {
if host == origin {
return true
}
}
return false
}
-148
View File
@@ -1,148 +0,0 @@
package webdav
import (
"context"
"log"
"net/http"
)
// CorsCfg is the CORS config.
type CorsCfg struct {
Enabled bool
AllowedHosts []string
}
// Config is the configuration of a WebDAV instance.
type Config struct {
*User
Auth bool
Cors CorsCfg
Users map[string]*User
}
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) {
u := c.User
requestOrigin := r.Header.Get("Origin")
// add cors headers before any operation so even on 401 unauthorized cors will working only when Origin header is present so request came from browser
if c.Cors.Enabled && requestOrigin != "" {
headers := w.Header()
if len(c.Cors.AllowedHosts) == 1 && c.Cors.AllowedHosts[0] == "*" {
headers.Set("Access-Control-Allow-Methods", "*")
headers.Set("Access-Control-Allow-Headers", "*")
headers.Set("Access-Control-Allow-Origin", "*")
} else if isAllowedHost(c.Cors.AllowedHosts, requestOrigin) {
headers.Set("Access-Control-Allow-Origin", requestOrigin)
headers.Set("Access-Control-Allow-Headers", "*")
headers.Set("Access-Control-Allow-Methods", "*")
}
}
if r.Method == "OPTIONS" && c.Cors.Enabled && requestOrigin != "" {
return
}
if c.Auth {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
// Gets the correct user for this request.
username, password, ok := r.BasicAuth()
if !ok {
http.Error(w, "Not authorized", 401)
return
}
user, ok := c.Users[username]
if !ok {
http.Error(w, "Not authorized", 401)
return
}
if !checkPassword(user.Password, password) {
log.Println("Wrong Password for user", username)
http.Error(w, "Not authorized", 401)
return
}
u = user
} else {
// Even if Auth is disabled, we might want to get
// the user from the Basic Auth header. Useful for Caddy
// plugin implementation.
username, _, ok := r.BasicAuth()
if ok {
if user, ok := c.Users[username]; ok {
u = user
}
}
}
// Checks for user permissions relatively to this PATH.
if !u.Allowed(r.URL.Path) {
w.WriteHeader(http.StatusForbidden)
return
}
if r.Method == "HEAD" {
w = newResponseWriterNoBody(w)
}
// If this request modified the files and the user doesn't have permission
// to do so, return forbidden.
if (r.Method == "PUT" || r.Method == "POST" || r.Method == "MKCOL" ||
r.Method == "DELETE" || r.Method == "COPY" || r.Method == "MOVE") &&
!u.Modify {
w.WriteHeader(http.StatusForbidden)
return
}
// Excerpt from RFC4918, section 9.4:
//
// GET, when applied to a collection, may return the contents of an
// "index.html" resource, a human-readable view of the contents of
// the collection, or something else altogether.
//
// Get, when applied to collection, will return the same as PROPFIND method.
if r.Method == "GET" {
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)
}