mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-07 01:52:37 -05:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0f6d53f49 | ||
|
|
d27ab6a00b | ||
|
|
f0fb859479 | ||
|
|
4c8a913377 | ||
|
|
c1684baffd | ||
|
|
18138b1ab5 | ||
|
|
98851e029f | ||
|
|
4a1a3bbe09 | ||
|
|
a6d7f7c64c | ||
|
|
ada7a18620 | ||
|
|
4f4ea2370e | ||
|
|
52aa314e1d | ||
|
|
1d6f67cc20 | ||
|
|
d89b7e7deb | ||
|
|
e26bd29848 | ||
|
|
9c2b98655a | ||
|
|
a379f047d1 | ||
|
|
f4879be6bf | ||
|
|
7751da745d | ||
|
|
80cedcb4d5 | ||
|
|
ac19abf7d2 | ||
|
|
38e868e5af | ||
|
|
b3af5cc2d7 | ||
|
|
b399de3d92 | ||
|
|
99f1a14e7b | ||
|
|
84afd8b0b7 | ||
|
|
78be5d8c0f | ||
|
|
5e29e1ee60 | ||
|
|
367a7f019c | ||
|
|
fa9c094160 | ||
|
|
36a9a6dc49 | ||
|
|
8fe6f1d5c3 | ||
|
|
6dc3935c57 | ||
|
|
5e73036657 | ||
|
|
e505900975 | ||
|
|
26f320ee43 | ||
|
|
184ae798c4 | ||
|
|
7ae8bab062 | ||
|
|
1af606e751 | ||
|
|
3683bc607c | ||
|
|
08d3e43c22 | ||
|
|
d76f9373c4 | ||
|
|
a72651e7aa | ||
|
|
73d0b20821 | ||
|
|
3e447307f7 | ||
|
|
264cc81c41 | ||
|
|
aa5b24119a | ||
|
|
52cf624711 | ||
|
|
5753f63970 | ||
|
|
8f1cf54cb0 | ||
|
|
a8cfcdd6eb |
No files matched your search
@@ -8,6 +8,7 @@ on:
|
||||
jobs:
|
||||
test-backend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -17,6 +18,7 @@ jobs:
|
||||
run: go test -race -v ./... -timeout 30s
|
||||
lint-backend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -28,6 +30,7 @@ jobs:
|
||||
cd backend && "$GOLANGCI_LINT" run --path-prefix=backend
|
||||
format-backend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -37,6 +40,7 @@ jobs:
|
||||
run: go fmt ./...
|
||||
lint-frontend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -46,6 +50,7 @@ jobs:
|
||||
run: npm i && npm run lint
|
||||
test-frontend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -55,6 +60,7 @@ jobs:
|
||||
run: npm i && npm run test
|
||||
check-translations:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -64,6 +70,7 @@ jobs:
|
||||
run: npm i && npm run i18n:check
|
||||
check-docs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -73,6 +80,7 @@ jobs:
|
||||
- run: git diff --exit-code
|
||||
check-icons:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
|
||||
@@ -2,6 +2,36 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v2.1.3
|
||||
|
||||
**Security**:
|
||||
- [Moderate] Passkey registration and deletion now require the account password when a passkey is already configured, and passkeys cannot be altered through generic user profile PATCH (GHSA-r744-4vm3-2px3) reporter @tonghuaroot
|
||||
- [Medium] Storage quota accounting now records upload byte deltas using the pre-replace file size, and upload paths without size metadata or with resumed chunk offsets cannot skip quota checks (GHSA-m57f-66qj-8jxg) reporter @jayhemnani9910
|
||||
- [High] Sources marked `readOnly` are now enforced on WebDAV writes, server-side archive/unarchive, and OnlyOffice save callbacks (GHSA-4v62-884x-2466) reporter @anpryl
|
||||
- [Moderate] Moving or renaming a folder updates only shares whose paths are under the moved prefix, preserving per-file share roots instead of retargeting unrelated substring matches (GHSA-26fh-r34c-qxh6) reporters @jayhemnani9910 @dylanzonix
|
||||
- [Moderate] WebDAV PUT now requires Create permission when the target path does not yet exist, so Modify-only users cannot create new files (GHSA-8655-2ph3-q7q7) reporter @levanduy181
|
||||
- [Low] Failed-login lockout and auth rate limits now use the rightmost `X-Forwarded-For` hop when proxy headers are trusted, so a client cannot spoof the client IP with a leftmost forwarded value (GHSA-wjp8-g49g-xrhx) reporter @LECOQQ
|
||||
- [Low] Duplicate finder results are cached per requesting user so filtered file names from one account are not served to another (GHSA-4fhx-8pf6-5gmr) reporter @Nazarami
|
||||
- [Moderate] Authenticated `GET /api/resources/view` no longer accepts view tokens minted for a public share when the caller lacks view permission on the requested source (GHSA-wxj5-g3qp-g732) reporter @BlueN0r
|
||||
- [Low] SSE source-update and user-targeted delivery hold the client registry lock through nonblocking sends, preventing concurrent map mutation and send-on-closed-channel panics when Realtime clients connect, disconnect, or shut down (GHSA-p9wm-f5jj-75pj) reporter @tao0845
|
||||
- [High] Incomplete symlink-containment remediation: an authenticated non-admin user could create a relative symlink via archive extraction, relocate it with WebDAV MOVE, and write through it to arbitrary host paths with the service account's privileges (GHSA-3j46-5w28-f8p2) reporter @Saku0512
|
||||
- [Moderate] Source-root containment bypass: create, write, delete, and move operations now resolve symlinks uncached and enforce the caller's scope, closing escapes through pre-existing out-of-source symlinks on upload, PUT, PATCH, delete, and WebDAV endpoints (GHSA-j764-qg3m-rhv7) reporter @chaoming0625
|
||||
- [Moderate] Preview reads resolve symlinks within the caller's scope so authenticated preview cannot follow an out-of-scope symlink target (GHSA-g2q5-r6ch-v25f) reporter @tao0845
|
||||
- [Moderate] Archive downloads and extraction no longer read or write through symlinks that resolve outside the caller's share or user scope; escaping archive-walk entries are skipped (GHSA-qqf3-2wvq-q4w9) reporter @SmokinCaterpillar
|
||||
- [Moderate] Share directory-archive downloads now apply the share owner's access rules to every entry, closing a bypass where anonymous share visitors could download admin-denied content (GHSA-9fcr-gm58-p839) reporter @Tan-JunWei
|
||||
- [Moderate] Access rule paths are stored and matched case-insensitively so rules cannot be bypassed via path casing on case-insensitive filesystems; existing rules are migrated at startup (GHSA-4vrc-935w-xxq2) reporter @FrogApprentice
|
||||
|
||||
**New Features**:
|
||||
- chunked uploads for large files like rclone `--owncloud-chunk-size` (#3112)
|
||||
|
||||
**Bug Fixes**:
|
||||
- keep requested permissions when creating a user (#3130)
|
||||
- add missing file modify logging to activity viewer (#3152)
|
||||
- fix(frontend): restore upload prompt close, tab-reload, and in-app navigation guards while uploads are active (including paused uploads)
|
||||
- fix(frontend): load the default source when browsing `/files/` with a single configured source ([#3178](https://github.com/gtsteffaniak/filebrowser/issues/3178)) (related: [#3143](https://github.com/gtsteffaniak/filebrowser/issues/3143), [#3144](https://github.com/gtsteffaniak/filebrowser/issues/3144))
|
||||
- Path picker list and thumbnails not working in shares in the markdown editor (#3223).
|
||||
- perf(indexing): single statx per Linux directory entry (#1890)
|
||||
|
||||
## v2.1.2
|
||||
|
||||
**Security**:
|
||||
|
||||
+23
-23
@@ -28,7 +28,7 @@ require (
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.67.4 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
github.com/alexkohler/prealloc v1.1.0 // indirect
|
||||
github.com/alfatraining/structtag v1.0.0 // indirect
|
||||
@@ -45,7 +45,7 @@ github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // i
|
||||
github.com/blizzy78/varnamelen v0.8.0 // indirect
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.9.0 // indirect
|
||||
github.com/breml/bidichk v0.3.3 // indirect
|
||||
github.com/breml/bidichk v0.3.4 // indirect
|
||||
github.com/breml/errchkjson v0.4.1 // indirect
|
||||
github.com/butuzov/ireturn v0.4.1 // indirect
|
||||
github.com/butuzov/mirror v1.3.3 // indirect
|
||||
@@ -54,7 +54,7 @@ github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // i
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charithe/durationcheck v0.0.11 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
@@ -101,16 +101,16 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indir
|
||||
github.com/gohugoio/hashstructure v1.1.0 // indirect
|
||||
github.com/gohugoio/hugo v0.165.0 // indirect
|
||||
github.com/golangci/asciicheck v0.5.0 // indirect
|
||||
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
|
||||
github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f // indirect
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
|
||||
github.com/golangci/golines v0.15.0 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.15.0 // indirect
|
||||
github.com/golangci/golines v0.16.0 // indirect
|
||||
github.com/golangci/misspell v0.8.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
github.com/golangci/revgrep v0.8.0 // indirect
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 // indirect
|
||||
github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8 // indirect
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
@@ -129,10 +129,10 @@ github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.20.0 // indirect
|
||||
github.com/kisielk/errcheck v1.30.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
github.com/kulti/thelper v0.7.1 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.15 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.16 // indirect
|
||||
github.com/ldez/exptostd v0.4.5 // indirect
|
||||
github.com/ldez/gomoddirectives v0.10.0 // indirect
|
||||
github.com/ldez/grignotin v0.10.1 // indirect
|
||||
@@ -147,17 +147,17 @@ github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
|
||||
github.com/maratori/testableexamples v1.0.1 // indirect
|
||||
github.com/maratori/testpackage v1.1.2 // indirect
|
||||
github.com/matoous/godox v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-colorable v0.1.16 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/mgechev/revive v1.17.0 // indirect
|
||||
github.com/mgechev/revive v1.17.1 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/moricho/tparallel v0.3.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nakabonne/nestif v0.3.1 // indirect
|
||||
github.com/nishanths/exhaustive v0.13.0 // indirect
|
||||
github.com/nishanths/predeclared v0.2.2 // indirect
|
||||
github.com/nishanths/exhaustive v0.15.0 // indirect
|
||||
github.com/nishanths/predeclared v0.3.0 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
@@ -200,15 +200,15 @@ github.com/mgechev/revive v1.17.0 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.16 // indirect
|
||||
github.com/tetafro/godot v1.5.6 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
|
||||
github.com/timonwong/loggercheck v0.12.0 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
|
||||
github.com/timonwong/loggercheck v0.13.0 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.13.0 // indirect
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
|
||||
github.com/ultraware/funlen v0.2.0 // indirect
|
||||
github.com/ultraware/whitespace v0.2.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.7 // indirect
|
||||
github.com/uudashr/gocognit v1.2.1 // indirect
|
||||
github.com/uudashr/iface v1.5.1 // indirect
|
||||
github.com/uudashr/gocognit v1.3.0 // indirect
|
||||
github.com/uudashr/iface v1.5.3 // indirect
|
||||
github.com/xen0n/gosmopolitan v1.3.0 // indirect
|
||||
github.com/xo/terminfo v1.2.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
@@ -225,14 +225,14 @@ github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/mod v0.42.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/tools v0.50.0 // indirect
|
||||
golang.org/x/tools v0.52.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
honnef.co/go/tools v0.8.1 // indirect
|
||||
honnef.co/go/tools v0.8.2 // indirect
|
||||
mvdan.cc/gofumpt v0.12.0 // indirect
|
||||
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
|
||||
@@ -124,6 +124,8 @@ github.com/bombsimon/wsl/v5 v5.9.0 h1:WCrgZ7RQnZO5oEwbVTlYgBdU3wL294kR1BSWV8vTfs
|
||||
github.com/bombsimon/wsl/v5 v5.9.0/go.mod h1:kjo4HiAV5FDkHC8/uzJq9mBffEEd6WT/nvN7DoMovDM=
|
||||
github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
|
||||
github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
|
||||
github.com/breml/bidichk v0.3.4 h1:70G6w80G6GDZTUURHTC5gBKVjMLMw2736CzXquSf5Ek=
|
||||
github.com/breml/bidichk v0.3.4/go.mod h1:GOHprnh4i0V7HrvWjtbvbpicTUcqoYt9zYESKcJkk4M=
|
||||
github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
|
||||
github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
|
||||
github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I=
|
||||
@@ -288,6 +290,8 @@ github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4n
|
||||
github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
|
||||
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 h1:fVLolA3dG6s0brGetsiDAtcnpMSwVa2LqXJEw/9RJG4=
|
||||
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6/go.mod h1:1xo+NFW5S+bEf2DKXhaxuzvcDN5AR6o/KMwvqC5Mkpo=
|
||||
github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f h1:pOnwRNeWLPkADfkgqKcCP7RPQAS9OIzffaBaktRsKnk=
|
||||
github.com/golangci/canonicalheader v0.0.0-20260928145534-e18e5c5a078f/go.mod h1:aXo+AVSX0pC3TRo2wAfR92YwBKfG6C2eal+omjKXwYw=
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A=
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
|
||||
github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U=
|
||||
@@ -298,8 +302,12 @@ github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80
|
||||
github.com/golangci/golangci-lint/v2 v2.13.2/go.mod h1:5xaMd1kAxV7GSBPEyngw8gnnjoRocqW9UVKdBR6627w=
|
||||
github.com/golangci/golangci-lint/v2 v2.14.0 h1:ot8QffRa4LzAAEgtvNYrVs9esxQ7xoTwoAv6uhp4ngA=
|
||||
github.com/golangci/golangci-lint/v2 v2.14.0/go.mod h1:amHimCzNDP1GfVno3hIiNssjvqXmuW+CPZAHOdR4cIc=
|
||||
github.com/golangci/golangci-lint/v2 v2.15.0 h1:w94zf8HAYI7hm3164kwKsvZRuqXvY+YxeFoTbu1OLg8=
|
||||
github.com/golangci/golangci-lint/v2 v2.15.0/go.mod h1:/FRI304IxqDVhX2o3ZZ0qp8Vvz8egJdzCY5A4GjvmTU=
|
||||
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
|
||||
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
|
||||
github.com/golangci/golines v0.16.0 h1:xKdwzRvwzLd7V131B/dv90vUiEoTA0vQybdeqperPOg=
|
||||
github.com/golangci/golines v0.16.0/go.mod h1:mCMgRQHiCvuALJOeaNtylu09zbzvS9Ka01dfvLLJios=
|
||||
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
|
||||
github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
|
||||
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
|
||||
@@ -308,6 +316,8 @@ github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2
|
||||
github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 h1:0kPqfqytQizeQ9WBk487NcsGcRdS8YhHeUhXxkfEAhA=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241/go.mod h1:lczq5oJ8PxpMESQG6dO3iFYTQPhpqoMOYw//n2FEnHA=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8 h1:oN042y1yEcpldrqY81puyNA4ZjQZJzt0U/t7HyYrMic=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20261005233414-e0740d52dee8/go.mod h1:T1Bg58Sk3N352aBbPLJPpHykFWxEEwV3o4/Enn+PPU8=
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
|
||||
@@ -365,6 +375,8 @@ github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhE
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
|
||||
github.com/kisielk/errcheck v1.20.0 h1:9rwHBNKzd4wkDWcROy3DvFGNqEPlkxBg305rvk7HabI=
|
||||
github.com/kisielk/errcheck v1.20.0/go.mod h1:O+f80MKNwX8Oor2jwgpeQ9An7uJm+hRSgT+h22knRJU=
|
||||
github.com/kisielk/errcheck v1.30.0 h1:ZIxNDAfAd4larXHTceoZP/dYPAB90dHg1OYhA2sVh5k=
|
||||
github.com/kisielk/errcheck v1.30.0/go.mod h1:uiCAInmFuyOje4VDdbQSP9mgqf5gORYSXrDf19XilsI=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -378,6 +390,8 @@ github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98=
|
||||
github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs=
|
||||
github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w=
|
||||
github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
|
||||
github.com/kunwardeep/paralleltest v1.0.16 h1:I0gQkLNgfigjpxWAGhx7PYj3VpswzfGrSV/715t6g+o=
|
||||
github.com/kunwardeep/paralleltest v1.0.16/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
|
||||
github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
|
||||
@@ -416,6 +430,8 @@ github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
|
||||
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
@@ -424,6 +440,8 @@ github.com/mgechev/revive v1.16.0 h1:9OZVO8xOPUwIYynplHXv7liMllPQ/BTNZLp07sM/Im0
|
||||
github.com/mgechev/revive v1.16.0/go.mod h1:nT854jMmvQsS/qqxEaMPM4pJSUpxhcPoHM788ZxDvdQ=
|
||||
github.com/mgechev/revive v1.17.0 h1:hwTTVC3svSv9X2FA2hiYT17Uws+lfxDWhPxzBZZCREg=
|
||||
github.com/mgechev/revive v1.17.0/go.mod h1:uiVNUF8ruVh4WHm/aPs7kKEeuo+m6oO3tuOfniyzP/g=
|
||||
github.com/mgechev/revive v1.17.1 h1:z8hV0L3uJ6yrwOMnJbkc5qBJpxXa2AxYKFKERGJyA4s=
|
||||
github.com/mgechev/revive v1.17.1/go.mod h1:FRd64pJwWSEE6j8ESoaX0PixPzjh2rpNSfckbEu3rIE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -444,8 +462,13 @@ github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlM
|
||||
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
|
||||
github.com/nishanths/exhaustive v0.13.0 h1:Rof0axOSNQaaPaPmLK5RxcRrJV2Vcv+vfUmuRy+2EAM=
|
||||
github.com/nishanths/exhaustive v0.13.0/go.mod h1:3/adQjJgvD/UFHHSYBjpK9Wbz8DOvf0FBtzx+zI0Rlo=
|
||||
github.com/nishanths/exhaustive v0.15.0 h1:uFjiO/WvrwQG+ojSZ+vtl/jAFbqA2oAtfpUWK/iKQ8M=
|
||||
github.com/nishanths/exhaustive v0.15.0/go.mod h1:2by9rYmg9FnsnLQC4Ixig4fQaWxRsY7RluxJuAu9sa0=
|
||||
github.com/nishanths/exhaustive v0.16.0/go.mod h1:/nBwAMRn2QmCjWQ3PiJ939yRSwvWLKSa21L2C6Tp84E=
|
||||
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
|
||||
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
|
||||
github.com/nishanths/predeclared v0.3.0 h1:W3i3JK/DZS5h7QwsEIyU8ler4Z2vwEwQSxNrZzhnlfk=
|
||||
github.com/nishanths/predeclared v0.3.0/go.mod h1:gN4uryPExDME/zmZdqkKIPSAqcfprj316ZDI6UTqz70=
|
||||
github.com/nunnatsa/ginkgolinter v0.24.0 h1:Mp0EagluLFP98JatP6nqp/gGEoljNG97uf9AcxcBVy8=
|
||||
github.com/nunnatsa/ginkgolinter v0.24.0/go.mod h1:2ZMRuzX6+3XXyY6UZOwb6n+MCocVGbkIsDBC4vuWz5c=
|
||||
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
|
||||
@@ -581,8 +604,12 @@ github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntW
|
||||
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2/go.mod h1:JLoPVtROtNgJ99yXTIxuGNttvmJa85tkJAO6lr05qBA=
|
||||
github.com/timonwong/loggercheck v0.12.0 h1:7Cb9XGLJZSWYNjgL459DIhpSiEHupD+MLQ6vaN2Pl3g=
|
||||
github.com/timonwong/loggercheck v0.12.0/go.mod h1:Wa6Y+SJHQR01mViZlLLY5GAZVOWOLNMbkXddMjFWQCs=
|
||||
github.com/timonwong/loggercheck v0.13.0 h1:6amIOmLLPFYt24rXUbNWzcJeNGuJc+3c31tZtO85VHs=
|
||||
github.com/timonwong/loggercheck v0.13.0/go.mod h1:thn3allpox0fI5JeUWTCwiwp45peQxCZZHkHuA1Oc6I=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.13.0 h1:jYbLNs8PDkzGCY6d5LUzJdY7Bq//CTlb2HxZGV5z0JY=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.13.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
|
||||
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
|
||||
@@ -593,8 +620,12 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
|
||||
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
|
||||
github.com/uudashr/gocognit v1.3.0 h1:Xdw5JwAoYmX3WAB+wsXY3nxEwdw7+MWAnM0wCAAv7A4=
|
||||
github.com/uudashr/gocognit v1.3.0/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
|
||||
github.com/uudashr/iface v1.5.1 h1:BS3yrgaT55s3dAtJoxuTnsZNbsejq/mVuzp49YDyfBA=
|
||||
github.com/uudashr/iface v1.5.1/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
|
||||
github.com/uudashr/iface v1.5.3 h1:N61bzr11L7BHP6Rct+C6zQePHjSTMeyVBq+s03uB/Ds=
|
||||
github.com/uudashr/iface v1.5.3/go.mod h1:5UWoT6SvTdTww/KToRj6clO+n4Kg3/NrAQvgOB+5Ggw=
|
||||
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
|
||||
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
|
||||
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
|
||||
@@ -661,6 +692,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -672,6 +705,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
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/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -681,6 +715,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -696,6 +732,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -718,6 +756,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
@@ -736,6 +776,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w=
|
||||
honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks=
|
||||
honnef.co/go/tools v0.8.2 h1:+jPbCzuYxYrcoOvCecYUFDj+XJQRmzIhJmlbpps21Zk=
|
||||
honnef.co/go/tools v0.8.2/go.mod h1:xG1Q4n+mg3bKZrO6RWFA9jFlkqplflmDRHGeuexiLDs=
|
||||
mvdan.cc/gofumpt v0.12.0 h1:1Lbudkz2kpM9Cjz2pL4M19u7q+GaEhCTNf7N9mfpcho=
|
||||
mvdan.cc/gofumpt v0.12.0/go.mod h1:SmBHHrljiZu/uoypeKup3rFzP6eoC9UwCp2iH5E3jZA=
|
||||
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 h1:Re1NRyLpiAt9kB+ImaaoapwWiQXrKwER4tY8fLOkDew=
|
||||
|
||||
@@ -289,6 +289,9 @@ func migrateUsers(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
|
||||
prevVersion := user.Version
|
||||
if len(user.BackendScopes) == 0 {
|
||||
settings.ApplyUserDefaults(user)
|
||||
if !user.Permissions.Admin {
|
||||
settings.ExpandBackendScopesForCreateUserDir(user)
|
||||
}
|
||||
}
|
||||
if prevVersion < users.SourcePermissionsMigrationVersion {
|
||||
// ApplyUserDefaults bumps Version; restore pre-defaults version so legacy global file ops migrate to scopes.
|
||||
|
||||
@@ -27,6 +27,9 @@ func TestMigrateUsersFlow_appliesConfigDefaultsAndSourcePermissions(t *testing.T
|
||||
prevVersion := user.Version
|
||||
if len(user.BackendScopes) == 0 {
|
||||
settings.ApplyUserDefaults(user)
|
||||
if !user.Permissions.Admin {
|
||||
settings.ExpandBackendScopesForCreateUserDir(user)
|
||||
}
|
||||
}
|
||||
if prevVersion < users.SourcePermissionsMigrationVersion {
|
||||
user.Version = prevVersion
|
||||
@@ -50,7 +53,7 @@ func TestMigrateUsersFlow_appliesConfigDefaultsAndSourcePermissions(t *testing.T
|
||||
t.Fatalf("scope permissions after migration: %+v", perms)
|
||||
}
|
||||
if user.BackendScopes[0].Scope != "/legacy-bolt-user" {
|
||||
t.Fatalf("createUserDir scope=%q", user.BackendScopes[0].Scope)
|
||||
t.Fatalf("scope after applying migration defaults=%q", user.BackendScopes[0].Scope)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-18
@@ -11,7 +11,7 @@ require (
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/evanoberholster/imagemeta v1.1.0
|
||||
github.com/gen2brain/go-fitz v1.28.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.14
|
||||
github.com/go-ldap/ldap/v3 v3.4.15
|
||||
github.com/go-playground/validator/v10 v10.30.5
|
||||
github.com/go-webauthn/webauthn v0.18.2
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
@@ -19,25 +19,25 @@ require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.2.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.4.0
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/kovidgoyal/imaging v1.8.23
|
||||
github.com/kovidgoyal/imaging v1.9.0
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/mod v0.41.0
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/image v0.47.0
|
||||
golang.org/x/mod v0.42.0
|
||||
golang.org/x/net v0.61.0
|
||||
golang.org/x/oauth2 v0.37.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/term v0.47.0
|
||||
golang.org/x/text v0.43.0
|
||||
golang.org/x/time v0.16.0
|
||||
golang.org/x/tools v0.50.0
|
||||
golang.org/x/tools v0.52.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
@@ -49,12 +49,12 @@ require (
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/ebitengine/purego v0.11.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/ebitengine/purego v0.11.2 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.16 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.2 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.3 // indirect
|
||||
github.com/go-openapi/spec v1.0.1 // indirect
|
||||
@@ -80,7 +80,7 @@ require (
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
@@ -90,12 +90,12 @@ require (
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.etcd.io/bbolt v1.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.77.1 // indirect
|
||||
modernc.org/libc v1.79.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
|
||||
+42
-42
@@ -47,14 +47,14 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
|
||||
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
|
||||
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
github.com/evanoberholster/imagemeta v1.1.0 h1:0iBtgVxLORZTZAlKxRcNodBmjMfHCSFwUh8Asmi83oI=
|
||||
github.com/evanoberholster/imagemeta v1.1.0/go.mod h1:nmf6le+qSyoRAXgp2psPbtfkxcEgf/AZGgai9pnN8d0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
|
||||
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E=
|
||||
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
|
||||
github.com/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJtW1M=
|
||||
github.com/gen2brain/go-fitz v1.28.2/go.mod h1:pY2hqAjp9Zy7qfPI2gwbJMHBFAdZpVXOLrRxD82l3Bs=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
@@ -65,10 +65,10 @@ github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWE
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
|
||||
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
|
||||
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
|
||||
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
|
||||
github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
|
||||
github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
|
||||
@@ -143,8 +143,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
|
||||
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.2.0 h1:WSFfRL5xVUpEXBNI4D3f2Uz8yxbc3OEqTtjp4Z9yPUo=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.2.0/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.4.0 h1:XkNFlx6koCCh8cFXn9HxYEEpvLlbnxmIvWX+5HLuN+8=
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.4.0/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
@@ -171,8 +171,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
|
||||
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
|
||||
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
|
||||
github.com/kovidgoyal/imaging v1.8.23 h1:4WsboyQ/E8tic2kX49P93Rvscg33SPsCwTBG5EZZF54=
|
||||
github.com/kovidgoyal/imaging v1.8.23/go.mod h1:k3Iot3H0v2rSWXIxQISfT3e9wIryqzAHLHCuhG7z9lM=
|
||||
github.com/kovidgoyal/imaging v1.9.0 h1:TxHvhClPANvDW7eLm7PYDTZU1KE15xbqlFv95DA6DmY=
|
||||
github.com/kovidgoyal/imaging v1.9.0/go.mod h1:t2vWMc3uISm/jMP87nBCcZUcAK6EDUdgumwaXSuz0Jc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -189,8 +189,8 @@ github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdo
|
||||
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
|
||||
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -233,13 +233,13 @@ 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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
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/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
|
||||
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -251,14 +251,14 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
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/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -270,28 +270,28 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
@@ -311,20 +311,20 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
||||
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
||||
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
|
||||
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
|
||||
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
|
||||
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
|
||||
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
|
||||
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
|
||||
@@ -163,8 +163,18 @@ func RecordPatchItem(r *http.Request, actor *Actor, action string, item MoveCopy
|
||||
RecordUser(r, actor, entry)
|
||||
}
|
||||
|
||||
// recordFileEvent records a file operation. Anonymous share visitors have no user ID,
|
||||
// so their uploads and deletes are attributed to the share owner.
|
||||
func recordFileEvent(r *http.Request, actor *Actor, entry activitydb.Entry) {
|
||||
if actor != nil && actor.Share.Hash != "" && (actor.User == nil || actor.User.ID == 0) {
|
||||
RecordShareOwner(r, actor, entry)
|
||||
return
|
||||
}
|
||||
RecordUser(r, actor, entry)
|
||||
}
|
||||
|
||||
func RecordDelete(r *http.Request, actor *Actor, source, path string) {
|
||||
RecordUser(r, actor, activitydb.Entry{
|
||||
recordFileEvent(r, actor, activitydb.Entry{
|
||||
EventType: activitydb.EventDelete,
|
||||
Source: source,
|
||||
Path: path,
|
||||
@@ -198,7 +208,7 @@ func RecordBulkDelete(r *http.Request, actor *Actor, succeeded []BulkDeleteItem)
|
||||
entry.Details.Source = succeeded[0].Source
|
||||
entry.Details.Path = succeeded[0].Path
|
||||
}
|
||||
RecordUser(r, actor, entry)
|
||||
recordFileEvent(r, actor, entry)
|
||||
}
|
||||
|
||||
func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool) {
|
||||
@@ -210,7 +220,7 @@ func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool
|
||||
Source: source,
|
||||
Path: pathLabel,
|
||||
}
|
||||
RecordUser(r, actor, activitydb.Entry{
|
||||
recordFileEvent(r, actor, activitydb.Entry{
|
||||
EventType: activitydb.EventUpload,
|
||||
Source: source,
|
||||
Path: pathLabel,
|
||||
@@ -218,6 +228,20 @@ func RecordUpload(r *http.Request, actor *Actor, source, path string, isDir bool
|
||||
})
|
||||
}
|
||||
|
||||
// RecordFileModify appends an audit row for an in-place file content update (PUT).
|
||||
func RecordFileModify(r *http.Request, actor *Actor, source, path string) {
|
||||
entry := activitydb.Entry{
|
||||
EventType: activitydb.EventFileModify,
|
||||
Source: source,
|
||||
Path: path,
|
||||
Details: activitydb.Details{
|
||||
Source: source,
|
||||
Path: path,
|
||||
},
|
||||
}
|
||||
recordFileEvent(r, actor, entry)
|
||||
}
|
||||
|
||||
func RecordShareMutation(r *http.Request, actor *Actor, eventType activitydb.EventType, hash, sourceName, path string, changes []activitydb.FieldChange) {
|
||||
details := activitydb.Details{
|
||||
Source: sourceName,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestAccessRuleCreateChanges(t *testing.T) {
|
||||
@@ -28,6 +32,86 @@ func TestAccessRuleDeleteChanges(t *testing.T) {
|
||||
assertFieldChange(t, changes[4], "count", "", "3")
|
||||
}
|
||||
|
||||
func TestShareFileEventsRecordedForShareOwner(t *testing.T) {
|
||||
store := &mockActivityStore{}
|
||||
globalMu.Lock()
|
||||
old := globalRecorder
|
||||
globalMu.Unlock()
|
||||
defer func() {
|
||||
globalMu.Lock()
|
||||
globalRecorder = old
|
||||
globalMu.Unlock()
|
||||
}()
|
||||
|
||||
rec := &Recorder{
|
||||
store: store,
|
||||
buffer: make([]activitydb.Entry, 0, 4),
|
||||
flushCh: make(chan struct{}, 1),
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
maxBuffer: 10000,
|
||||
flushInterval: time.Hour,
|
||||
enabled: true,
|
||||
}
|
||||
globalMu.Lock()
|
||||
globalRecorder = rec
|
||||
globalMu.Unlock()
|
||||
go rec.loop()
|
||||
|
||||
actor := &Actor{
|
||||
User: &users.User{FrontendUser: users.FrontendUser{Username: "anonymous"}},
|
||||
Share: share.Share{Hash: "share-hash", UserID: 7},
|
||||
}
|
||||
r := httptest.NewRequest("POST", "/public/api/resources", nil)
|
||||
RecordUpload(r, actor, "main", "/pub/new.txt", false)
|
||||
RecordDelete(r, actor, "main", "/pub/old.txt")
|
||||
RecordBulkDelete(r, actor, []BulkDeleteItem{{Source: "main", Path: "/pub/a.txt"}})
|
||||
loggedIn := &Actor{
|
||||
User: &users.User{ID: 5, FrontendUser: users.FrontendUser{Username: "bob"}},
|
||||
Share: share.Share{Hash: "share-hash", UserID: 7},
|
||||
}
|
||||
RecordUpload(r, loggedIn, "main", "/pub/bob.txt", false)
|
||||
RecordFileModify(r, loggedIn, "main", "/pub/edited.txt")
|
||||
RecordFileModify(r, actor, "main", "/pub/anon-edit.txt")
|
||||
rec.Stop()
|
||||
|
||||
var got []activitydb.Entry
|
||||
for _, batch := range store.inserts {
|
||||
got = append(got, batch...)
|
||||
}
|
||||
want := []activitydb.EventType{
|
||||
activitydb.EventUpload,
|
||||
activitydb.EventDelete,
|
||||
activitydb.EventBulkDelete,
|
||||
activitydb.EventUpload,
|
||||
activitydb.EventFileModify,
|
||||
activitydb.EventFileModify,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %d entries, got %d: %+v", len(want), len(got), got)
|
||||
}
|
||||
for i, e := range got {
|
||||
if e.EventType != want[i] {
|
||||
t.Fatalf("entry %d: event type %q, want %q", i, e.EventType, want[i])
|
||||
}
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
e := got[i]
|
||||
if e.UserID != 7 || e.Details.ShareHash != "share-hash" {
|
||||
t.Fatalf("anonymous share entry %d: userID=%d shareHash=%q", i, e.UserID, e.Details.ShareHash)
|
||||
}
|
||||
}
|
||||
if got[3].UserID != 5 {
|
||||
t.Fatalf("logged-in share visitor upload: userID=%d, want 5", got[3].UserID)
|
||||
}
|
||||
if got[4].UserID != 5 {
|
||||
t.Fatalf("logged-in share visitor modify: userID=%d, want 5", got[4].UserID)
|
||||
}
|
||||
if got[5].UserID != 7 || got[5].Details.ShareHash != "share-hash" {
|
||||
t.Fatalf("anonymous share modify: userID=%d shareHash=%q", got[5].UserID, got[5].Details.ShareHash)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFieldChange(t *testing.T, change activitydb.FieldChange, field, from, to string) {
|
||||
t.Helper()
|
||||
if change.Field != field || change.From != from || change.To != to {
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
|
||||
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func Test_GetRealPath(t *testing.T) {
|
||||
@@ -129,7 +129,7 @@ func TestWriteFilePreservesExecutableBit(t *testing.T) {
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
indexPath := "/" + scriptName
|
||||
if err := WriteFile(sourceName, indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
|
||||
if err := WriteFile(sourceName, "", indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ func TestWriteFileRejectsExistingDirectory(t *testing.T) {
|
||||
defer stopPoll()
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
err := WriteFile(sourceName, "/"+dirName, strings.NewReader("file content"))
|
||||
err := WriteFile(sourceName, "", "/"+dirName, strings.NewReader("file content"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when path is an existing directory")
|
||||
}
|
||||
@@ -571,7 +571,7 @@ func TestDeleteFilesRootProtection(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test: Attempting to delete the root directory should return an error
|
||||
err := DeleteFiles("test_root_protection", realTmpDir, true)
|
||||
err := DeleteFiles("test_root_protection", "", "/", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error when trying to delete root directory")
|
||||
}
|
||||
@@ -583,13 +583,13 @@ func TestDeleteFilesRootProtection(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test with trailing slash - should still be blocked
|
||||
err = DeleteFiles("test_root_protection", realTmpDir+"/", true)
|
||||
err = DeleteFiles("test_root_protection", "", "/", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error for root directory with trailing slash")
|
||||
}
|
||||
|
||||
// Test with cleaned path variations
|
||||
err = DeleteFiles("test_root_protection", realTmpDir+"/.", true)
|
||||
err = DeleteFiles("test_root_protection", "", "/", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error for root directory with /. suffix")
|
||||
}
|
||||
@@ -670,7 +670,7 @@ func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
|
||||
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
err := DeleteFiles(sourceName, subfolderPath, true)
|
||||
err := DeleteFiles(sourceName, "", "/"+subfolderName, true)
|
||||
stopPolling()
|
||||
|
||||
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
|
||||
@@ -694,13 +694,130 @@ func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
|
||||
t.Logf("iteration %d: cleaned scanners and indices", iter)
|
||||
}
|
||||
|
||||
// TestDeleteFiles_RemovesSymlinkNotTarget verifies that deleting a symlink leaf
|
||||
// removes the link itself and never follows it to the target.
|
||||
func TestDeleteFiles_RemovesSymlinkNotTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(filepath.Dir(root), "outside-victim.txt")
|
||||
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName := "test_delete_symlink"
|
||||
indexing.SetTestIndex(sourceName, root)
|
||||
t.Cleanup(indexing.ClearTestIndices)
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
idx.Config.ResolvedRules.IndexingDisabled = true
|
||||
|
||||
if err := DeleteFiles(sourceName, "", "/link", false); err != nil {
|
||||
t.Fatalf("DeleteFiles: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, "link")); !os.IsNotExist(err) {
|
||||
t.Fatal("symlink should have been removed")
|
||||
}
|
||||
got, err := os.ReadFile(outside)
|
||||
if err != nil || string(got) != "keep me" {
|
||||
t.Fatalf("delete followed the symlink and removed the target: %v %q", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFiles_RejectsEscapingIntermediate verifies a delete under an
|
||||
// intermediate symlink escaping the source is refused.
|
||||
func TestDeleteFiles_RejectsEscapingIntermediate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outsideDir := filepath.Join(filepath.Dir(root), "outside-dir")
|
||||
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideDir, filepath.Join(root, "evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
victim := filepath.Join(outsideDir, "victim.txt")
|
||||
if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName := "test_delete_escape"
|
||||
indexing.SetTestIndex(sourceName, root)
|
||||
t.Cleanup(indexing.ClearTestIndices)
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
idx.Config.ResolvedRules.IndexingDisabled = true
|
||||
|
||||
err := DeleteFiles(sourceName, "", "/evil/victim.txt", false)
|
||||
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(victim); err != nil {
|
||||
t.Fatal("delete escaped the bound and removed the outside file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteFile_RejectsSymlinkEscape verifies a write through a symlink leaf
|
||||
// resolving outside the source is refused before any bytes are written.
|
||||
func TestWriteFile_RejectsSymlinkEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(filepath.Dir(root), "outside.txt")
|
||||
if err := os.WriteFile(outside, []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "evil")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName := "test_write_symlink"
|
||||
indexing.SetTestIndex(sourceName, root)
|
||||
t.Cleanup(indexing.ClearTestIndices)
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
idx.Config.ResolvedRules.IndexingDisabled = true
|
||||
|
||||
err := WriteFile(sourceName, "", "/evil", strings.NewReader("pwned"))
|
||||
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
|
||||
}
|
||||
got, _ := os.ReadFile(outside)
|
||||
if string(got) != "original" {
|
||||
t.Fatal("write went through the symlink")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteFile_RejectsMissingLeafUnderEscapingParent verifies the j764/3j46
|
||||
// primitive: writing a new file under a symlinked directory that escapes the
|
||||
// source is refused even though the leaf does not exist yet.
|
||||
func TestWriteFile_RejectsMissingLeafUnderEscapingParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outsideDir := filepath.Join(filepath.Dir(root), "outside-parent")
|
||||
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideDir, filepath.Join(root, "evil_dir")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName := "test_write_escape_parent"
|
||||
indexing.SetTestIndex(sourceName, root)
|
||||
t.Cleanup(indexing.ClearTestIndices)
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
idx.Config.ResolvedRules.IndexingDisabled = true
|
||||
|
||||
err := WriteFile(sourceName, "", "/evil_dir/hack.txt", strings.NewReader("pwned"))
|
||||
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(outsideDir, "hack.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("write landed outside the source through the symlinked parent")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForScannerReady(t *testing.T, idx *indexing.Index) {
|
||||
t.Helper()
|
||||
// Give the scanners a moment to initialize, but don't wait for full "ready" status
|
||||
// The initial scan can take longer than the test timeout, but DeleteFiles will work
|
||||
// as long as the scanners are initialized and running
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
|
||||
// Verify scanners exist
|
||||
status := idx.GetScannerStatus()
|
||||
if totalScanners, ok := status["totalScanners"].(int); !ok || totalScanners == 0 {
|
||||
|
||||
@@ -71,13 +71,18 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
indexPath := idx.MakeIndexPath(response.Path, true).String()
|
||||
err := idx.RefreshDirectory(indexPath, false)
|
||||
if err != nil {
|
||||
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
|
||||
}
|
||||
})
|
||||
// Expanded listings already persisted index rows and the folder size from
|
||||
// the freshly read directory (persistDirectoryListing); only unexpanded
|
||||
// directory requests still need a refresh walk.
|
||||
if !opts.Expand {
|
||||
wg.Go(func() {
|
||||
indexPath := idx.MakeIndexPath(response.Path, true).String()
|
||||
err := idx.RefreshDirectory(indexPath, false)
|
||||
if err != nil {
|
||||
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if opts.Metadata && metadataCount > 0 {
|
||||
addMetadataToChildren(response, opts, &wg)
|
||||
@@ -629,47 +634,53 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
// DeleteFiles deletes the file or directory at indexPath. boundIndexPath limits
|
||||
// symlink resolution to the caller's scope ("" or "/" = source root only). The
|
||||
// entry itself is removed: a symlink leaf is deleted, never followed, and an
|
||||
// intermediate symlinked component cannot carry the delete outside the bound.
|
||||
func DeleteFiles(source, boundIndexPath, indexPath string, isDir bool) error {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
// Safety guard: never allow deletion of the source root directory itself
|
||||
cleanAbs := filepath.Clean(absPath)
|
||||
if cleanAbs == filepath.Clean(index.Path) {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
|
||||
if utils.CleanIndexPathSegment(indexPath) == "" {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", indexPath)
|
||||
}
|
||||
|
||||
if !index.Config.ResolvedRules.IndexingDisabled {
|
||||
indexPath := index.MakeIndexPath(absPath, isDir).String()
|
||||
|
||||
// Perform the physical deletion
|
||||
err := os.RemoveAll(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear cache entries
|
||||
indexing.RealPathCache.Delete(absPath)
|
||||
indexing.IsDirCache.Delete(absPath + ":isdir")
|
||||
|
||||
// Remove metadata from index
|
||||
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
|
||||
if !deleteSuccess {
|
||||
logger.Errorf("Failed to delete metadata from index for %s, but filesystem deletion succeeded", indexPath)
|
||||
}
|
||||
|
||||
// Refresh the parent directory to recalculate sizes and update counts
|
||||
refreshConfig := utils.FileOptions{
|
||||
Path: index.MakeIndexPath(filepath.Dir(absPath), true).String(),
|
||||
IsDir: true,
|
||||
}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
realPath, err := index.GetRealPathForRemoval(boundIndexPath, indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Indexing disabled, just delete the file
|
||||
return os.RemoveAll(absPath)
|
||||
if index.Config.ResolvedRules.IndexingDisabled {
|
||||
// Indexing disabled, just delete the file
|
||||
return os.RemoveAll(realPath)
|
||||
}
|
||||
|
||||
indexPath = index.MakeIndexPath(indexPath, isDir).String()
|
||||
|
||||
// Perform the physical deletion
|
||||
err = os.RemoveAll(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear cache entries
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.IsDirCache.Delete(realPath + ":isdir")
|
||||
|
||||
// Remove metadata from index
|
||||
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
|
||||
if !deleteSuccess {
|
||||
logger.Errorf("Failed to delete metadata from index for %s, but filesystem deletion succeeded", indexPath)
|
||||
}
|
||||
|
||||
// Refresh the parent directory to recalculate sizes and update counts
|
||||
refreshConfig := utils.FileOptions{
|
||||
Path: index.MakeIndexPath(filepath.Dir(strings.TrimSuffix(indexPath, "/")), true).String(),
|
||||
IsDir: true,
|
||||
}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
return err
|
||||
}
|
||||
|
||||
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
|
||||
@@ -896,15 +907,19 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteDirectory(opts utils.FileOptions) error {
|
||||
// WriteDirectory creates a directory at opts.Path. boundIndexPath limits symlink
|
||||
// resolution to the caller's scope ("" or "/" = source root only).
|
||||
func WriteDirectory(boundIndexPath string, opts utils.FileOptions) error {
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(opts.Path)
|
||||
realPath, err := idx.GetRealPathForMutation(boundIndexPath, opts.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var stat os.FileInfo
|
||||
var err error
|
||||
// Check if the destination exists and is a file
|
||||
if stat, err = os.Stat(realPath); err == nil && !stat.IsDir() {
|
||||
// If it's a file and we're trying to create a directory, remove the file first
|
||||
@@ -948,21 +963,24 @@ func WriteDirectory(opts utils.FileOptions) error {
|
||||
// WriteFileFunc is a variable that can be mocked in tests
|
||||
var WriteFileFunc = writeFile
|
||||
|
||||
func WriteFile(source, path string, in io.Reader) error {
|
||||
return WriteFileFunc(source, path, in)
|
||||
// WriteFile writes stream contents to the file at index path path. boundIndexPath
|
||||
// limits symlink resolution to the caller's scope ("" or "/" = source root only).
|
||||
func WriteFile(source, boundIndexPath, path string, in io.Reader) error {
|
||||
return WriteFileFunc(source, boundIndexPath, path, in)
|
||||
}
|
||||
|
||||
func writeFile(source, path string, in io.Reader) error {
|
||||
func writeFile(source, boundIndexPath, path string, in io.Reader) error {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
// Strip trailing slash from realPath if it's meant to be a file
|
||||
realPath = strings.TrimRight(realPath, "/")
|
||||
realPath, err := idx.GetRealPathForMutation(boundIndexPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Ensure the parent directories exist
|
||||
parentDir := filepath.Dir(realPath)
|
||||
err := os.MkdirAll(parentDir, fileutils.PermDir)
|
||||
err = os.MkdirAll(parentDir, fileutils.PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -19,11 +19,13 @@ func MakeUserDir(fullPath string) error {
|
||||
}
|
||||
|
||||
func MakeUserDirs(u *users.User, createDir bool) error {
|
||||
if u.Permissions.Admin {
|
||||
return nil
|
||||
}
|
||||
cleanedUserName := users.CleanUsername(u.Username)
|
||||
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
|
||||
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
|
||||
}
|
||||
settings.ExpandBackendScopesForCreateUserDir(u)
|
||||
for i, scope := range u.BackendScopes {
|
||||
source, ok := settings.Config.Server.SourceMap[scope.Path]
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestMakeUserDirsSkipsAdmins(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
saved := settings.Config
|
||||
t.Cleanup(func() { settings.Config = saved })
|
||||
settings.Config = settings.Settings{
|
||||
Server: settings.Server{
|
||||
Sources: []*settings.Source{{
|
||||
Path: root,
|
||||
Config: settings.SourceConfig{
|
||||
CreateUserDir: true,
|
||||
},
|
||||
}},
|
||||
SourceMap: map[string]*settings.Source{
|
||||
root: {
|
||||
Path: root,
|
||||
Config: settings.SourceConfig{
|
||||
CreateUserDir: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
Permissions: users.Permissions{
|
||||
Admin: true,
|
||||
},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{{Path: root, Scope: "/"}},
|
||||
}
|
||||
|
||||
if err := MakeUserDirs(u, true); err != nil {
|
||||
t.Fatalf("MakeUserDirs: %v", err)
|
||||
}
|
||||
if got := u.BackendScopes[0].Scope; got != "/" {
|
||||
t.Fatalf("scope=%q want /", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "admin")); !os.IsNotExist(err) {
|
||||
t.Fatalf("admin directory stat error=%v, want not-exist", err)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,22 @@ func unixModeToFileMode(u uint32) os.FileMode {
|
||||
return m
|
||||
}
|
||||
|
||||
// removeDestinationSymlinkIfPresent deletes dest when it is a symlink so a copy or
|
||||
// cross-device move replaces the link entry instead of writing through it.
|
||||
func removeDestinationSymlinkIfPresent(dest string) error {
|
||||
info, err := os.Lstat(dest)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return os.Remove(dest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveFile moves a file from src to dst.
|
||||
// By default, the rename system call is used. If src and dst are on different volumes,
|
||||
// CopyFile is used as a fallback and the source is removed synchronously on success.
|
||||
@@ -134,6 +150,10 @@ func copySingleFile(source, dest string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = removeDestinationSymlinkIfPresent(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the destination file with source permissions
|
||||
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
|
||||
if err != nil {
|
||||
|
||||
@@ -356,6 +356,74 @@ func TestCopySymlinkReplacesExistingDestination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFile_ReplacesDestinationSymlinkWithoutFollowingTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := filepath.Join(filepath.Dir(dir), "outside-victim.txt")
|
||||
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(dir, "link")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
if err := os.WriteFile(src, []byte("copied"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CopyFile(src, link); err != nil {
|
||||
t.Fatalf("CopyFile: %v", err)
|
||||
}
|
||||
info, err := os.Lstat(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatal("destination should be a regular file replacing the symlink entry")
|
||||
}
|
||||
got, err := os.ReadFile(outside)
|
||||
if err != nil || string(got) != "keep me" {
|
||||
t.Fatalf("copy followed the symlink target: %v %q", err, got)
|
||||
}
|
||||
if body, err := os.ReadFile(link); err != nil || string(body) != "copied" {
|
||||
t.Fatalf("link path content = %q, err=%v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFile_ReplacesDestinationSymlinkWithoutFollowingTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := filepath.Join(filepath.Dir(dir), "outside-victim.txt")
|
||||
if err := os.WriteFile(outside, []byte("keep me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(dir, "link")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Join(dir, "src.txt")
|
||||
if err := os.WriteFile(src, []byte("moved"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := MoveFile(src, link); err != nil {
|
||||
t.Fatalf("MoveFile: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(src); !os.IsNotExist(err) {
|
||||
t.Fatal("source should be gone after move")
|
||||
}
|
||||
info, err := os.Lstat(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatal("destination should be a regular file replacing the symlink entry")
|
||||
}
|
||||
got, err := os.ReadFile(outside)
|
||||
if err != nil || string(got) != "keep me" {
|
||||
t.Fatalf("move followed the symlink target: %v %q", err, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
|
||||
const content = "complete file contents"
|
||||
src := struct{ io.Reader }{bytes.NewBufferString(content)}
|
||||
|
||||
@@ -3,7 +3,6 @@ package access
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -125,6 +124,9 @@ type RuleUpsert struct {
|
||||
type SQLPersister interface {
|
||||
SaveAccessRule(source, path string, rule *AccessRule) error
|
||||
DeleteAccessRule(source, path string) error
|
||||
MigrateAccessRulePathsLowercase(source string, oldPaths []string, newRules RuleMap) error
|
||||
AccessRulesLowercaseMigrated() (bool, error)
|
||||
SetAccessRulesLowercaseMigrated() error
|
||||
SaveGroup(name string, members StringSet) error
|
||||
DeleteGroup(name string) error
|
||||
DeleteGroupWithRules(name string, save []RuleUpsert, remove []RuleKey) error
|
||||
@@ -264,7 +266,7 @@ func (s *Storage) restoreRuleSQLNL(key RuleKey, rule *AccessRule) error {
|
||||
|
||||
// RemoveRuleByPath removes a rule by normalized index path.
|
||||
func (s *Storage) RemoveRuleByPath(sourcePath string, indexPath utils.IndexPath) {
|
||||
s.RemoveRuleByPathKey(sourcePath, ruleKey(indexPath))
|
||||
s.RemoveRuleByPathKey(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
}
|
||||
|
||||
// RemoveRuleByPathKey removes a rule by its exact storage key (for legacy migration paths).
|
||||
@@ -277,6 +279,7 @@ func (s *Storage) RemoveRuleByPathKey(sourcePath, pathKey string) {
|
||||
return
|
||||
}
|
||||
|
||||
pathKey = strings.ToLower(pathKey)
|
||||
if _, exists := rulesBySource[pathKey]; !exists {
|
||||
return
|
||||
}
|
||||
@@ -289,7 +292,7 @@ func (s *Storage) RemoveRuleByPathKey(sourcePath, pathKey string) {
|
||||
if s.sqlStore != nil {
|
||||
_ = s.sqlStore.DeleteAccessRule(sourcePath, pathKey)
|
||||
normalized := utils.AddTrailingSlashIfNotExists(pathKey)
|
||||
if normalized != pathKey {
|
||||
if normalized != pathKey && rulesBySource[normalized] == nil {
|
||||
_ = s.sqlStore.DeleteAccessRule(sourcePath, normalized)
|
||||
}
|
||||
}
|
||||
@@ -298,7 +301,7 @@ func (s *Storage) RemoveRuleByPathKey(sourcePath, pathKey string) {
|
||||
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
|
||||
// The caller must hold the lock.
|
||||
func (s *Storage) getOrCreateRuleNL(sourcePath string, indexPath utils.IndexPath) *AccessRule {
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
if _, ok := s.AllRules[sourcePath]; !ok {
|
||||
s.AllRules[sourcePath] = make(RuleMap)
|
||||
}
|
||||
@@ -309,6 +312,19 @@ func (s *Storage) getOrCreateRuleNL(sourcePath string, indexPath utils.IndexPath
|
||||
Allow: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
|
||||
}
|
||||
s.AllRules[sourcePath][normalizedPath] = rule
|
||||
} else {
|
||||
if rule.Deny.Users == nil {
|
||||
rule.Deny.Users = make(StringSet)
|
||||
}
|
||||
if rule.Deny.Groups == nil {
|
||||
rule.Deny.Groups = make(StringSet)
|
||||
}
|
||||
if rule.Allow.Users == nil {
|
||||
rule.Allow.Users = make(StringSet)
|
||||
}
|
||||
if rule.Allow.Groups == nil {
|
||||
rule.Allow.Groups = make(StringSet)
|
||||
}
|
||||
}
|
||||
return rule
|
||||
}
|
||||
@@ -324,7 +340,7 @@ func (s *Storage) DenyUser(sourcePath string, indexPath utils.IndexPath, usernam
|
||||
}
|
||||
rule.Deny.Users[username] = struct{}{}
|
||||
s.clearAllCaches()
|
||||
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
|
||||
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -339,7 +355,7 @@ func (s *Storage) AllowUser(sourcePath string, indexPath utils.IndexPath, userna
|
||||
}
|
||||
rule.Allow.Users[username] = struct{}{}
|
||||
s.clearAllCaches()
|
||||
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
|
||||
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -357,7 +373,7 @@ func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupn
|
||||
}
|
||||
rule.Deny.Groups[groupname] = struct{}{}
|
||||
s.clearAllCaches()
|
||||
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
|
||||
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -375,7 +391,7 @@ func (s *Storage) AllowGroup(sourcePath string, indexPath utils.IndexPath, group
|
||||
}
|
||||
rule.Allow.Groups[groupname] = struct{}{}
|
||||
s.clearAllCaches()
|
||||
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
|
||||
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -389,13 +405,13 @@ func (s *Storage) DenyAll(sourcePath string, indexPath utils.IndexPath) error {
|
||||
}
|
||||
rule.DenyAll = true
|
||||
s.clearAllCaches()
|
||||
s.persistRuleSQLNL(sourcePath, ruleKey(indexPath))
|
||||
s.persistRuleSQLNL(sourcePath, s.ruleKeyForNL(sourcePath, indexPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
|
||||
func (s *Storage) Permitted(sourcePath string, indexPath utils.IndexPath, username string) bool {
|
||||
indexPath = checkPath(indexPath)
|
||||
indexPath = s.checkPathForSource(sourcePath, indexPath)
|
||||
pathStr := indexPath.String()
|
||||
|
||||
// Get current version for the sourcePath
|
||||
@@ -468,7 +484,7 @@ func (s *Storage) getRuleAtExactPath(sourcePath string, indexPath utils.IndexPat
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
rule, ok := rulesBySource[ruleKey(indexPath)]
|
||||
rule, ok := rulesBySource[s.ruleKeyForNL(sourcePath, indexPath)]
|
||||
return rule, ok
|
||||
}
|
||||
|
||||
@@ -527,7 +543,7 @@ func (s *Storage) GetFrontendRules(sourcePath string, indexPath utils.IndexPath)
|
||||
}
|
||||
|
||||
// Check if path exists on filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath.String()))
|
||||
pathExists := checkRulePathExists(sourcePath, indexPath.String())
|
||||
|
||||
frontendRules := FrontendAccessRule{
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
@@ -545,7 +561,7 @@ func (s *Storage) GetFrontendRules(sourcePath string, indexPath utils.IndexPath)
|
||||
if !ok {
|
||||
return frontendRules, false
|
||||
}
|
||||
rule, ok := rulesBySource[ruleKey(indexPath)]
|
||||
rule, ok := rulesBySource[s.ruleKeyForNL(sourcePath, indexPath)]
|
||||
if !ok || rule == nil {
|
||||
return frontendRules, false
|
||||
}
|
||||
@@ -592,7 +608,7 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
|
||||
frontendPath := indexPath
|
||||
|
||||
// Check if path exists on filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
|
||||
pathExists := checkRulePathExists(sourcePath, indexPath)
|
||||
|
||||
// Convert AccessRule to FrontendAccessRule
|
||||
frontendRules[frontendPath] = FrontendAccessRule{
|
||||
@@ -1020,7 +1036,7 @@ func (s *Storage) RenameUserInRules(oldName, newName string) error {
|
||||
func (s *Storage) RemoveAllowUser(sourcePath string, indexPath utils.IndexPath, username string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -1031,11 +1047,8 @@ func (s *Storage) RemoveAllowUser(sourcePath string, indexPath utils.IndexPath,
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
s.deleteStoredRuleNL(sourcePath, normalizedPath)
|
||||
}
|
||||
if removed {
|
||||
s.clearAllCaches()
|
||||
@@ -1049,7 +1062,7 @@ func (s *Storage) RemoveAllowUser(sourcePath string, indexPath utils.IndexPath,
|
||||
func (s *Storage) RemoveAllowGroup(sourcePath string, indexPath utils.IndexPath, groupname string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -1060,11 +1073,8 @@ func (s *Storage) RemoveAllowGroup(sourcePath string, indexPath utils.IndexPath,
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
s.deleteStoredRuleNL(sourcePath, normalizedPath)
|
||||
}
|
||||
if removed {
|
||||
s.clearAllCaches()
|
||||
@@ -1078,7 +1088,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath string, indexPath utils.IndexPath,
|
||||
func (s *Storage) RemoveDenyUser(sourcePath string, indexPath utils.IndexPath, username string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -1089,11 +1099,8 @@ func (s *Storage) RemoveDenyUser(sourcePath string, indexPath utils.IndexPath, u
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
s.deleteStoredRuleNL(sourcePath, normalizedPath)
|
||||
}
|
||||
if removed {
|
||||
s.clearAllCaches()
|
||||
@@ -1107,7 +1114,7 @@ func (s *Storage) RemoveDenyUser(sourcePath string, indexPath utils.IndexPath, u
|
||||
func (s *Storage) RemoveDenyGroup(sourcePath string, indexPath utils.IndexPath, groupname string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -1118,11 +1125,8 @@ func (s *Storage) RemoveDenyGroup(sourcePath string, indexPath utils.IndexPath,
|
||||
}
|
||||
removed := exists
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
s.deleteStoredRuleNL(sourcePath, normalizedPath)
|
||||
}
|
||||
if removed {
|
||||
s.clearAllCaches()
|
||||
@@ -1136,7 +1140,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath string, indexPath utils.IndexPath,
|
||||
func (s *Storage) RemoveDenyAll(sourcePath string, indexPath utils.IndexPath) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -1147,11 +1151,8 @@ func (s *Storage) RemoveDenyAll(sourcePath string, indexPath utils.IndexPath) (b
|
||||
removed = true
|
||||
}
|
||||
// If rule is now empty, remove it
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
|
||||
delete(s.AllRules[sourcePath], normalizedPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
if !accessRuleHasPayload(rule) {
|
||||
s.deleteStoredRuleNL(sourcePath, normalizedPath)
|
||||
}
|
||||
if removed {
|
||||
s.clearAllCaches()
|
||||
@@ -1193,10 +1194,7 @@ func (s *Storage) RemoveAllRulesForUser(username string) error {
|
||||
if rule.DenyAll || len(rule.Allow.Users) > 0 || len(rule.Allow.Groups) > 0 || len(rule.Deny.Users) > 0 || len(rule.Deny.Groups) > 0 {
|
||||
continue
|
||||
}
|
||||
delete(s.AllRules[key.Source], key.Path)
|
||||
if len(s.AllRules[key.Source]) == 0 {
|
||||
delete(s.AllRules, key.Source)
|
||||
}
|
||||
s.deleteStoredRuleNL(key.Source, key.Path)
|
||||
}
|
||||
var persisted []RuleKey
|
||||
for _, key := range keys {
|
||||
@@ -1240,10 +1238,7 @@ func (s *Storage) removeAllRulesForGroupNL(groupname string) ([]RuleKey, map[Rul
|
||||
delete(rule.Deny.Groups, groupname)
|
||||
// A rule left with only DenyAll must stay, otherwise removing the group would open the path.
|
||||
if !accessRuleHasPayload(rule) {
|
||||
delete(s.AllRules[sourcePath], indexPath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
s.deleteStoredRuleNL(sourcePath, indexPath)
|
||||
}
|
||||
touched = append(touched, key)
|
||||
}
|
||||
@@ -1485,7 +1480,7 @@ func (s *Storage) RemoveUserCascade(sourcePath string, indexPath utils.IndexPath
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
@@ -1521,7 +1516,7 @@ func (s *Storage) RemoveUserCascade(sourcePath string, indexPath utils.IndexPath
|
||||
// If rule is now empty, mark it for deletion
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
|
||||
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
s.deleteStoredRuleNL(sourcePath, rulePath)
|
||||
pathChanged = true
|
||||
}
|
||||
if pathChanged {
|
||||
@@ -1530,11 +1525,6 @@ func (s *Storage) RemoveUserCascade(sourcePath string, indexPath utils.IndexPath
|
||||
}
|
||||
}
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.clearAllCaches()
|
||||
for rp := range touchedPaths {
|
||||
@@ -1553,7 +1543,7 @@ func (s *Storage) RemoveGroupCascade(sourcePath string, indexPath utils.IndexPat
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
normalizedPath := ruleKey(indexPath)
|
||||
normalizedPath := s.ruleKeyForNL(sourcePath, indexPath)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
@@ -1589,7 +1579,7 @@ func (s *Storage) RemoveGroupCascade(sourcePath string, indexPath utils.IndexPat
|
||||
// If rule is now empty, mark it for deletion
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
|
||||
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
s.deleteStoredRuleNL(sourcePath, rulePath)
|
||||
pathChanged = true
|
||||
}
|
||||
if pathChanged {
|
||||
@@ -1598,11 +1588,6 @@ func (s *Storage) RemoveGroupCascade(sourcePath string, indexPath utils.IndexPat
|
||||
}
|
||||
}
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.clearAllCaches()
|
||||
for rp := range touchedPaths {
|
||||
@@ -1625,11 +1610,15 @@ func (s *Storage) UpdateRules(sourcePath string, oldPath, newPath utils.IndexPat
|
||||
return 0, nil // No rules for this source, not an error
|
||||
}
|
||||
|
||||
oldKey := ruleKey(oldPath)
|
||||
newKey := ruleKey(newPath)
|
||||
oldKey := s.ruleKeyForNL(sourcePath, oldPath)
|
||||
newKey := s.ruleKeyForNL(sourcePath, newPath)
|
||||
if oldKey == newKey {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
updated := 0
|
||||
rulesToUpdate := make(map[string]string) // old path -> new path
|
||||
movedRules := make(map[string]*AccessRule)
|
||||
|
||||
// Find all rules that need to be updated
|
||||
for rulePath := range rulesBySource {
|
||||
@@ -1641,11 +1630,20 @@ func (s *Storage) UpdateRules(sourcePath string, oldPath, newPath utils.IndexPat
|
||||
}
|
||||
}
|
||||
|
||||
// Update all matched rules
|
||||
for oldRulePath, newRulePath := range rulesToUpdate {
|
||||
rule := rulesBySource[oldRulePath]
|
||||
// Remove all matched rules first so overlapping moves cannot clobber each other
|
||||
for oldRulePath := range rulesToUpdate {
|
||||
movedRules[oldRulePath] = rulesBySource[oldRulePath]
|
||||
delete(rulesBySource, oldRulePath)
|
||||
rulesBySource[newRulePath] = rule
|
||||
}
|
||||
|
||||
// Insert or merge into target paths
|
||||
for oldRulePath, newRulePath := range rulesToUpdate {
|
||||
rule := movedRules[oldRulePath]
|
||||
if existing, exists := rulesBySource[newRulePath]; exists {
|
||||
mergeAccessRulesInPlace(existing, rule)
|
||||
} else {
|
||||
rulesBySource[newRulePath] = rule
|
||||
}
|
||||
logger.Info("access rule updated", "source", sourcePath, "fromPath", oldRulePath, "toPath", newRulePath)
|
||||
updated++
|
||||
}
|
||||
@@ -1653,7 +1651,7 @@ func (s *Storage) UpdateRules(sourcePath string, oldPath, newPath utils.IndexPat
|
||||
if updated > 0 {
|
||||
s.clearAllCaches()
|
||||
for oldRulePath := range rulesToUpdate {
|
||||
if s.sqlStore != nil {
|
||||
if s.sqlStore != nil && rulesBySource[oldRulePath] == nil {
|
||||
_ = s.sqlStore.DeleteAccessRule(sourcePath, oldRulePath)
|
||||
}
|
||||
}
|
||||
@@ -1670,8 +1668,8 @@ func (s *Storage) UpdateRulePath(sourcePath string, oldPath, newPath utils.Index
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
oldKey := ruleKey(oldPath)
|
||||
newKey := ruleKey(newPath)
|
||||
oldKey := s.ruleKeyForNL(sourcePath, oldPath)
|
||||
newKey := s.ruleKeyForNL(sourcePath, newPath)
|
||||
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
@@ -1682,9 +1680,16 @@ func (s *Storage) UpdateRulePath(sourcePath string, oldPath, newPath utils.Index
|
||||
if !ok {
|
||||
return fmt.Errorf("no rule found for path: %s", oldKey)
|
||||
}
|
||||
if oldKey == newKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(rulesBySource, oldKey)
|
||||
rulesBySource[newKey] = rule
|
||||
if existing, exists := rulesBySource[newKey]; exists {
|
||||
mergeAccessRulesInPlace(existing, rule)
|
||||
} else {
|
||||
rulesBySource[newKey] = rule
|
||||
}
|
||||
s.clearAllCaches()
|
||||
if s.sqlStore != nil {
|
||||
_ = s.sqlStore.DeleteAccessRule(sourcePath, oldKey)
|
||||
|
||||
@@ -16,6 +16,11 @@ type stubGroupSQL struct {
|
||||
|
||||
func (s *stubGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
|
||||
func (s *stubGroupSQL) DeleteAccessRule(string, string) error { return nil }
|
||||
func (s *stubGroupSQL) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
|
||||
func (s *stubGroupSQL) SetAccessRulesLowercaseMigrated() error { return nil }
|
||||
func (s *stubGroupSQL) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubGroupSQL) SaveGroup(string, access.StringSet) error {
|
||||
return s.saveErr
|
||||
}
|
||||
@@ -43,6 +48,11 @@ type recordingGroupSQL struct {
|
||||
|
||||
func (s *recordingGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
|
||||
func (s *recordingGroupSQL) DeleteAccessRule(string, string) error { return nil }
|
||||
func (s *recordingGroupSQL) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
|
||||
func (s *recordingGroupSQL) SetAccessRulesLowercaseMigrated() error { return nil }
|
||||
func (s *recordingGroupSQL) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
|
||||
return nil
|
||||
}
|
||||
func (s *recordingGroupSQL) SaveGroup(name string, members access.StringSet) error {
|
||||
if s.groups == nil {
|
||||
s.groups = make(map[string]access.StringSet)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Access rules are stored and matched using lowercase path keys (strings.ToLower).
|
||||
|
||||
// checkRulePathExists reports whether rulePath exists under sourcePath, matching
|
||||
// path segments case-insensitively when the exact path is not found on case-sensitive filesystems.
|
||||
func checkRulePathExists(sourcePath, rulePath string) bool {
|
||||
if utils.CheckPathExists(filepath.Join(sourcePath, rulePath)) {
|
||||
return true
|
||||
}
|
||||
rel := strings.Trim(strings.TrimSpace(rulePath), "/")
|
||||
if rel == "" {
|
||||
return utils.CheckPathExists(sourcePath)
|
||||
}
|
||||
current := sourcePath
|
||||
for _, part := range strings.Split(rel, "/") {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
candidate := filepath.Join(current, part)
|
||||
if utils.CheckPathExists(candidate) {
|
||||
current = candidate
|
||||
continue
|
||||
}
|
||||
entries, err := os.ReadDir(current)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
partLower := strings.ToLower(part)
|
||||
matched := ""
|
||||
for _, e := range entries {
|
||||
if strings.ToLower(e.Name()) == partLower {
|
||||
matched = e.Name()
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == "" {
|
||||
return false
|
||||
}
|
||||
current = filepath.Join(current, matched)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ruleKeyForNL returns the storage key; caller must hold s.mux.
|
||||
func (s *Storage) ruleKeyForNL(_ string, indexPath utils.IndexPath) string {
|
||||
return strings.ToLower(ruleKey(indexPath))
|
||||
}
|
||||
|
||||
// ruleKeyFor returns the storage/lookup key for a rule path on this source.
|
||||
func (s *Storage) ruleKeyFor(sourcePath string, indexPath utils.IndexPath) string {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
return s.ruleKeyForNL(sourcePath, indexPath)
|
||||
}
|
||||
|
||||
func (s *Storage) checkPathForSourceNL(_ string, indexPath utils.IndexPath) utils.IndexPath {
|
||||
p := checkPath(indexPath)
|
||||
return utils.IndexPathFromNormalized(strings.ToLower(p.String()), p.IsDir)
|
||||
}
|
||||
|
||||
// checkPathForSource normalizes an index path for permission checks on a source.
|
||||
func (s *Storage) checkPathForSource(sourcePath string, indexPath utils.IndexPath) utils.IndexPath {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
return s.checkPathForSourceNL(sourcePath, indexPath)
|
||||
}
|
||||
|
||||
// StartupMigrateAccessRuleKeys lowercases existing rule keys in memory and SQL,
|
||||
// merging colliding paths. Idempotent when keys are already lowercase.
|
||||
func (s *Storage) StartupMigrateAccessRuleKeys() error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
for sourcePath := range s.AllRules {
|
||||
if err := s.migrateSourceRulesLowercaseNL(sourcePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.sqlStore == nil {
|
||||
return nil
|
||||
}
|
||||
migrated, err := s.sqlStore.AccessRulesLowercaseMigrated()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if migrated {
|
||||
return nil
|
||||
}
|
||||
return s.sqlStore.SetAccessRulesLowercaseMigrated()
|
||||
}
|
||||
|
||||
func (s *Storage) migrateSourceRulesLowercaseNL(sourcePath string) error {
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok || len(rulesBySource) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !rulesNeedLowercaseMigration(rulesBySource) {
|
||||
return nil
|
||||
}
|
||||
|
||||
oldPaths := make([]string, 0, len(rulesBySource))
|
||||
newRules := make(RuleMap)
|
||||
|
||||
for path, rule := range rulesBySource {
|
||||
oldPaths = append(oldPaths, path)
|
||||
lower := strings.ToLower(path)
|
||||
if existing, exists := newRules[lower]; exists {
|
||||
logger.Infof("access rules: merging colliding paths %q into %q on source %s", path, lower, sourcePath)
|
||||
mergeAccessRulesInPlace(existing, rule)
|
||||
continue
|
||||
}
|
||||
fresh := &AccessRule{}
|
||||
mergeAccessRulesInPlace(fresh, rule)
|
||||
newRules[lower] = fresh
|
||||
if lower != path {
|
||||
logger.Infof("access rules: migrated rule path %q -> %q on source %s", path, lower, sourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
s.AllRules[sourcePath] = newRules
|
||||
|
||||
if s.sqlStore == nil {
|
||||
return nil
|
||||
}
|
||||
return s.sqlStore.MigrateAccessRulePathsLowercase(sourcePath, oldPaths, newRules)
|
||||
}
|
||||
|
||||
func rulesNeedLowercaseMigration(rules RuleMap) bool {
|
||||
byLower := make(map[string]string)
|
||||
for path := range rules {
|
||||
lower := strings.ToLower(path)
|
||||
if path != lower {
|
||||
return true
|
||||
}
|
||||
if other, ok := byLower[lower]; ok && other != path {
|
||||
return true
|
||||
}
|
||||
byLower[lower] = path
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeAccessRulesInPlace(dst, src *AccessRule) {
|
||||
if src == nil || dst == nil {
|
||||
return
|
||||
}
|
||||
if src.DenyAll {
|
||||
dst.DenyAll = true
|
||||
}
|
||||
if dst.Deny.Users == nil {
|
||||
dst.Deny.Users = StringSet{}
|
||||
}
|
||||
if dst.Deny.Groups == nil {
|
||||
dst.Deny.Groups = StringSet{}
|
||||
}
|
||||
if dst.Allow.Users == nil {
|
||||
dst.Allow.Users = StringSet{}
|
||||
}
|
||||
if dst.Allow.Groups == nil {
|
||||
dst.Allow.Groups = StringSet{}
|
||||
}
|
||||
for u := range src.Deny.Users {
|
||||
dst.Deny.Users[u] = struct{}{}
|
||||
}
|
||||
for g := range src.Deny.Groups {
|
||||
dst.Deny.Groups[g] = struct{}{}
|
||||
}
|
||||
for u := range src.Allow.Users {
|
||||
dst.Allow.Users[u] = struct{}{}
|
||||
}
|
||||
for g := range src.Allow.Groups {
|
||||
dst.Allow.Groups[g] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Storage) deleteStoredRuleNL(sourcePath, rulePath string) {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package access_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
)
|
||||
|
||||
func TestStartupMigrationMergesCollidingRules(t *testing.T) {
|
||||
store, userStore, sqlStore := createTestStorageWithSQL(t)
|
||||
_ = store
|
||||
_ = userStore
|
||||
|
||||
ruleA := &access.AccessRule{
|
||||
Deny: access.RuleSet{Users: map[string]struct{}{"alice": {}}},
|
||||
}
|
||||
ruleB := &access.AccessRule{
|
||||
Deny: access.RuleSet{Users: map[string]struct{}{"bob": {}}},
|
||||
}
|
||||
if err := sqlStore.SaveAccessRule("/src", "/Private/", ruleA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sqlStore.SaveAccessRule("/src", "/private/", ruleB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := access.NewStorage(nil)
|
||||
rules, err := sqlStore.GetAllAccessRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.AllRules = rules
|
||||
s.SetSQLStore(sqlStore)
|
||||
|
||||
if startErr := s.StartupMigrateAccessRuleKeys(); startErr != nil {
|
||||
t.Fatal(startErr)
|
||||
}
|
||||
byPath, err := sqlStore.GetAccessRulesBySource("/src")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(byPath) != 1 {
|
||||
t.Fatalf("expected one merged rule row, got %d", len(byPath))
|
||||
}
|
||||
merged := byPath["/private/"]
|
||||
if merged == nil {
|
||||
t.Fatal("expected merged rule at lowercase key")
|
||||
}
|
||||
if _, ok := merged.Deny.Users["alice"]; !ok {
|
||||
t.Fatal("missing alice from merge")
|
||||
}
|
||||
if _, ok := merged.Deny.Users["bob"]; !ok {
|
||||
t.Fatal("missing bob from merge")
|
||||
}
|
||||
|
||||
migrated, err := sqlStore.AccessRulesLowercaseMigrated()
|
||||
if err != nil || !migrated {
|
||||
t.Fatal("marker should be set after first migration")
|
||||
}
|
||||
byPath2, err := sqlStore.GetAccessRulesBySource("/src")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(byPath2) != 1 {
|
||||
t.Fatal("rules should remain after marker is set without re-running migration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package access_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
)
|
||||
|
||||
type migrateCallCounter struct {
|
||||
access.SQLPersister
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *migrateCallCounter) MigrateAccessRulePathsLowercase(source string, oldPaths []string, newRules access.RuleMap) error {
|
||||
m.calls++
|
||||
return m.SQLPersister.MigrateAccessRulePathsLowercase(source, oldPaths, newRules)
|
||||
}
|
||||
|
||||
type failAccessRuleMigratePersister struct {
|
||||
access.SQLPersister
|
||||
}
|
||||
|
||||
func (failAccessRuleMigratePersister) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
|
||||
return errors.New("simulated migration failure")
|
||||
}
|
||||
|
||||
func TestFreshDBSetsAccessRulesLowercaseMarker(t *testing.T) {
|
||||
s, _, sqlStore := createTestStorageWithSQL(t)
|
||||
s.SetSQLStore(sqlStore)
|
||||
|
||||
if err := s.StartupMigrateAccessRuleKeys(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, err := sqlStore.AccessRulesLowercaseMigrated()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected migration marker on empty database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeMigratesAndSetsMarker(t *testing.T) {
|
||||
_, _, sqlStore := createTestStorageWithSQL(t)
|
||||
|
||||
rule := &access.AccessRule{
|
||||
Deny: access.RuleSet{Users: map[string]struct{}{"alice": {}}},
|
||||
}
|
||||
if err := sqlStore.SaveAccessRule("/src", "/Private/", rule); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := access.NewStorage(nil)
|
||||
rules, err := sqlStore.GetAllAccessRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.AllRules = rules
|
||||
counter := &migrateCallCounter{SQLPersister: sqlStore}
|
||||
s.SetSQLStore(counter)
|
||||
|
||||
if err = s.StartupMigrateAccessRuleKeys(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counter.calls != 1 {
|
||||
t.Fatalf("expected one SQL migration, got %d", counter.calls)
|
||||
}
|
||||
ok, err := sqlStore.AccessRulesLowercaseMigrated()
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("marker should be set after upgrade, ok=%v err=%v", ok, err)
|
||||
}
|
||||
byPath, err := sqlStore.GetAccessRulesBySource("/src")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if byPath["/private/"] == nil {
|
||||
t.Fatal("expected lowercased rule row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondStartupSkipsAccessRulesMigration(t *testing.T) {
|
||||
_, _, sqlStore := createTestStorageWithSQL(t)
|
||||
if err := sqlStore.SaveAccessRule("/src", "/Private/", &access.AccessRule{
|
||||
Deny: access.RuleSet{Users: map[string]struct{}{"u": {}}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := access.NewStorage(nil)
|
||||
rules, err := sqlStore.GetAllAccessRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.AllRules = rules
|
||||
counter := &migrateCallCounter{SQLPersister: sqlStore}
|
||||
s.SetSQLStore(counter)
|
||||
|
||||
if err = s.StartupMigrateAccessRuleKeys(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counter.calls != 1 {
|
||||
t.Fatalf("first startup: expected 1 migrate call, got %d", counter.calls)
|
||||
}
|
||||
|
||||
already, err := sqlStore.AccessRulesLowercaseMigrated()
|
||||
if err != nil || !already {
|
||||
t.Fatal("marker should be set after first startup")
|
||||
}
|
||||
|
||||
s2 := access.NewStorage(nil)
|
||||
rules2, err := sqlStore.GetAllAccessRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2.AllRules = rules2
|
||||
s2.SetSQLStore(counter)
|
||||
if err = s2.StartupMigrateAccessRuleKeys(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counter.calls != 1 {
|
||||
t.Fatalf("second startup must not invoke SQL migration, got %d calls", counter.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationFailureDoesNotSetMarker(t *testing.T) {
|
||||
_, _, sqlStore := createTestStorageWithSQL(t)
|
||||
if err := sqlStore.SaveAccessRule("/src", "/Private/", &access.AccessRule{
|
||||
Deny: access.RuleSet{Users: map[string]struct{}{"u": {}}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := access.NewStorage(nil)
|
||||
rules, err := sqlStore.GetAllAccessRules()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.AllRules = rules
|
||||
s.SetSQLStore(failAccessRuleMigratePersister{SQLPersister: sqlStore})
|
||||
|
||||
if err = s.StartupMigrateAccessRuleKeys(); err == nil {
|
||||
t.Fatal("expected migration failure")
|
||||
}
|
||||
ok, err := sqlStore.AccessRulesLowercaseMigrated()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("marker must not be set when migration fails")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestPermittedDenyCaseVariant(t *testing.T) {
|
||||
settings.Config = settings.Settings{
|
||||
Server: settings.Server{
|
||||
SourceMap: map[string]*settings.Source{
|
||||
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
},
|
||||
},
|
||||
}
|
||||
s := NewStorage(nil)
|
||||
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/private/secret.txt", false), "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := s.ruleKeyFor("/data", utils.IndexPathFromNormalized("/private/secret.txt", false))
|
||||
if key != "/private/secret.txt/" {
|
||||
t.Fatalf("stored key %q", key)
|
||||
}
|
||||
if s.Permitted("/data", utils.IndexPathFromNormalized("/PRIVATE/secret.txt", false), "alice") {
|
||||
t.Fatal("case variant must hit deny rule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRuleByPathKeyCaseVariant(t *testing.T) {
|
||||
settings.Config = settings.Settings{
|
||||
Server: settings.Server{
|
||||
SourceMap: map[string]*settings.Source{
|
||||
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
},
|
||||
},
|
||||
}
|
||||
s := NewStorage(nil)
|
||||
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/private/x", false), "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.RemoveRuleByPathKey("/data", "/PRIVATE/x/")
|
||||
if !s.Permitted("/data", utils.IndexPathFromNormalized("/private/x", false), "alice") {
|
||||
t.Fatal("rule should be removed when key casing differs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermittedHotPathNoFilesystemAccess(t *testing.T) {
|
||||
settings.Config = settings.Settings{
|
||||
Server: settings.Server{
|
||||
SourceMap: map[string]*settings.Source{
|
||||
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
},
|
||||
},
|
||||
}
|
||||
s := NewStorage(nil)
|
||||
_ = s.DenyUser("/data", utils.IndexPathFromNormalized("/a/b", false), "u")
|
||||
if !s.Permitted("/data", utils.IndexPathFromNormalized("/A/B", false), "other") {
|
||||
t.Fatal("expected permitted for non-denied user")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPermittedLowercaseNoFS(b *testing.B) {
|
||||
settings.Config = settings.Settings{
|
||||
Server: settings.Server{
|
||||
SourceMap: map[string]*settings.Source{
|
||||
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
},
|
||||
},
|
||||
}
|
||||
s := NewStorage(nil)
|
||||
_ = s.DenyUser("/data", utils.IndexPathFromNormalized("/x/y", false), "u")
|
||||
path := utils.IndexPathFromNormalized("/X/Y", false)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.Permitted("/data", path, "bob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesNeedLowercaseMigration(t *testing.T) {
|
||||
if rulesNeedLowercaseMigration(RuleMap{"/ok/": {}}) {
|
||||
t.Fatal("already lowercase")
|
||||
}
|
||||
if !rulesNeedLowercaseMigration(RuleMap{"/Private/": {}}) {
|
||||
t.Fatal("uppercase segment should need migration")
|
||||
}
|
||||
if !rulesNeedLowercaseMigration(RuleMap{"/Private/": {}, "/private/": {}}) {
|
||||
t.Fatal("case collision should need migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRulePathExistsMixedCase(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mixedDir := filepath.Join(root, "MyFolder", "SubDir")
|
||||
if err := os.MkdirAll(mixedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mixedFile := filepath.Join(mixedDir, "Readme.MD")
|
||||
if err := os.WriteFile(mixedFile, []byte("ok"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
settings.Config = settings.Settings{
|
||||
Server: settings.Server{
|
||||
SourceMap: map[string]*settings.Source{
|
||||
root: {Path: root, Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
},
|
||||
},
|
||||
}
|
||||
s := NewStorage(nil)
|
||||
if err := s.DenyUser(root, utils.IndexPathFromNormalized("/MyFolder/SubDir/Readme.MD", false), "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all, err := s.GetAllRules(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule, ok := all["/myfolder/subdir/readme.md/"]
|
||||
if !ok {
|
||||
t.Fatalf("expected lowercased rule key in GetAllRules, got %v", all)
|
||||
}
|
||||
if !rule.PathExists {
|
||||
t.Fatal("expected PathExists=true for mixed-case path on disk")
|
||||
}
|
||||
|
||||
feRule, ok := s.GetFrontendRules(root, utils.IndexPathFromNormalized("/myfolder/subdir/readme.md", false))
|
||||
if !ok {
|
||||
t.Fatal("expected rule from GetFrontendRules with lowercase query")
|
||||
}
|
||||
if !feRule.PathExists {
|
||||
t.Fatal("expected PathExists=true in GetFrontendRules")
|
||||
}
|
||||
|
||||
if checkRulePathExists(root, "/myfolder/subdir/nonexistent.txt/") {
|
||||
t.Fatal("expected false for missing path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRulesAndUpdateRulePathCaseOnlyAndCollision(t *testing.T) {
|
||||
settings.Config = settings.Settings{
|
||||
Server: settings.Server{
|
||||
SourceMap: map[string]*settings.Source{
|
||||
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
},
|
||||
},
|
||||
}
|
||||
s := NewStorage(nil)
|
||||
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/MyFolder", true), "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Case-only rename is a no-op and preserves the rule.
|
||||
updated, err := s.UpdateRules("/data", utils.IndexPathFromNormalized("/MyFolder", true), utils.IndexPathFromNormalized("/myfolder", true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated != 0 {
|
||||
t.Fatalf("expected 0 updated for case-only rename, got %d", updated)
|
||||
}
|
||||
if s.Permitted("/data", utils.IndexPathFromNormalized("/MyFolder", true), "alice") {
|
||||
t.Fatal("rule should still deny alice after case-only rename")
|
||||
}
|
||||
|
||||
if err := s.UpdateRulePath("/data", utils.IndexPathFromNormalized("/MyFolder", true), utils.IndexPathFromNormalized("/myfolder", true)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Permitted("/data", utils.IndexPathFromNormalized("/myfolder", true), "alice") {
|
||||
t.Fatal("rule should still deny alice after case-only UpdateRulePath")
|
||||
}
|
||||
|
||||
// Colliding target rule merges entries instead of overwriting.
|
||||
if err := s.DenyUser("/data", utils.IndexPathFromNormalized("/Archive", true), "bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpdateRulePath("/data", utils.IndexPathFromNormalized("/MyFolder", true), utils.IndexPathFromNormalized("/archive", true)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Permitted("/data", utils.IndexPathFromNormalized("/archive", true), "alice") ||
|
||||
s.Permitted("/data", utils.IndexPathFromNormalized("/archive", true), "bob") {
|
||||
t.Fatal("expected merged rule at /archive/ to deny both alice and bob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAllowUserPreservesDenyAll(t *testing.T) {
|
||||
settings.Config = settings.Settings{
|
||||
Server: settings.Server{
|
||||
SourceMap: map[string]*settings.Source{
|
||||
"/data": {Path: "/data", Config: settings.SourceConfig{DenyByDefault: false}},
|
||||
},
|
||||
},
|
||||
}
|
||||
s := NewStorage(nil)
|
||||
p := utils.IndexPathFromNormalized("/Secret", true)
|
||||
if err := s.DenyAll("/data", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AllowUser("/data", p, "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
removed, err := s.RemoveAllowUser("/data", p, "alice")
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("RemoveAllowUser failed: removed=%v err=%v", removed, err)
|
||||
}
|
||||
if s.Permitted("/data", p, "bob") {
|
||||
t.Fatal("DenyAll must remain active after removing the last allowed user")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ type failingRevokePersister struct{}
|
||||
|
||||
func (failingRevokePersister) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
|
||||
func (failingRevokePersister) DeleteAccessRule(string, string) error { return nil }
|
||||
func (failingRevokePersister) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
|
||||
func (failingRevokePersister) SetAccessRulesLowercaseMigrated() error { return nil }
|
||||
func (failingRevokePersister) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
|
||||
return nil
|
||||
}
|
||||
func (failingRevokePersister) SaveGroup(string, access.StringSet) error { return nil }
|
||||
func (failingRevokePersister) DeleteGroup(string) error { return nil }
|
||||
func (failingRevokePersister) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
|
||||
|
||||
@@ -6,34 +6,35 @@ import "fmt"
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventDownload EventType = "download"
|
||||
EventMove EventType = "move"
|
||||
EventCopy EventType = "copy"
|
||||
EventRename EventType = "rename"
|
||||
EventUpload EventType = "upload"
|
||||
EventDelete EventType = "delete"
|
||||
EventBulkDelete EventType = "bulkDelete"
|
||||
EventArchive EventType = "archive"
|
||||
EventUnarchive EventType = "unarchive"
|
||||
EventShareCreate EventType = "shareCreate"
|
||||
EventShareUpdate EventType = "shareUpdate"
|
||||
EventShareDelete EventType = "shareDelete"
|
||||
EventUserCreate EventType = "userCreate"
|
||||
EventUserUpdate EventType = "userUpdate"
|
||||
EventUserDelete EventType = "userDelete"
|
||||
EventAccessUpdate EventType = "accessUpdate"
|
||||
EventAccessCreate EventType = "accessCreate"
|
||||
EventAccessDelete EventType = "accessDelete"
|
||||
EventQuotaCreate EventType = "quotaCreate"
|
||||
EventQuotaUpdate EventType = "quotaUpdate"
|
||||
EventQuotaDelete EventType = "quotaDelete"
|
||||
EventLogin EventType = "login"
|
||||
EventLogout EventType = "logout"
|
||||
EventSignup EventType = "signup"
|
||||
EventPasskeyRegister EventType = "passkeyRegister"
|
||||
EventPasskeyDelete EventType = "passkeyDelete"
|
||||
EventTokenCreate EventType = "tokenCreate"
|
||||
EventTokenDelete EventType = "tokenDelete"
|
||||
EventDownload EventType = "download"
|
||||
EventMove EventType = "move"
|
||||
EventCopy EventType = "copy"
|
||||
EventRename EventType = "rename"
|
||||
EventUpload EventType = "upload"
|
||||
EventFileModify EventType = "fileModify"
|
||||
EventDelete EventType = "delete"
|
||||
EventBulkDelete EventType = "bulkDelete"
|
||||
EventArchive EventType = "archive"
|
||||
EventUnarchive EventType = "unarchive"
|
||||
EventShareCreate EventType = "shareCreate"
|
||||
EventShareUpdate EventType = "shareUpdate"
|
||||
EventShareDelete EventType = "shareDelete"
|
||||
EventUserCreate EventType = "userCreate"
|
||||
EventUserUpdate EventType = "userUpdate"
|
||||
EventUserDelete EventType = "userDelete"
|
||||
EventAccessUpdate EventType = "accessUpdate"
|
||||
EventAccessCreate EventType = "accessCreate"
|
||||
EventAccessDelete EventType = "accessDelete"
|
||||
EventQuotaCreate EventType = "quotaCreate"
|
||||
EventQuotaUpdate EventType = "quotaUpdate"
|
||||
EventQuotaDelete EventType = "quotaDelete"
|
||||
EventLogin EventType = "login"
|
||||
EventLogout EventType = "logout"
|
||||
EventSignup EventType = "signup"
|
||||
EventPasskeyRegister EventType = "passkeyRegister"
|
||||
EventPasskeyDelete EventType = "passkeyDelete"
|
||||
EventTokenCreate EventType = "tokenCreate"
|
||||
EventTokenDelete EventType = "tokenDelete"
|
||||
EventDuplicateFinder EventType = "duplicateFinder"
|
||||
)
|
||||
|
||||
@@ -44,6 +45,7 @@ var AllEventTypes = []EventType{
|
||||
EventCopy,
|
||||
EventRename,
|
||||
EventUpload,
|
||||
EventFileModify,
|
||||
EventDelete,
|
||||
EventBulkDelete,
|
||||
EventArchive,
|
||||
@@ -77,6 +79,7 @@ var FileEventTypes = []EventType{
|
||||
EventCopy,
|
||||
EventRename,
|
||||
EventUpload,
|
||||
EventFileModify,
|
||||
EventDelete,
|
||||
EventBulkDelete,
|
||||
EventArchive,
|
||||
@@ -155,7 +158,7 @@ func intersectEventTypes(requested, allowed []EventType) []EventType {
|
||||
func (e EventType) Valid() bool {
|
||||
switch e {
|
||||
case EventDownload, EventMove, EventCopy, EventRename,
|
||||
EventUpload, EventDelete, EventBulkDelete,
|
||||
EventUpload, EventFileModify, EventDelete, EventBulkDelete,
|
||||
EventArchive, EventUnarchive,
|
||||
EventShareCreate, EventShareUpdate, EventShareDelete,
|
||||
EventUserCreate, EventUserUpdate, EventUserDelete, EventAccessUpdate, EventAccessCreate, EventAccessDelete,
|
||||
|
||||
@@ -145,15 +145,20 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
|
||||
if l == nil || l.SourcePath != oldSource {
|
||||
continue
|
||||
}
|
||||
shareWasDir := strings.HasSuffix(l.Path, "/")
|
||||
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
|
||||
pos := strings.Index(l.Path, oldPath)
|
||||
if pos < 0 {
|
||||
norm := utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
if !strings.HasPrefix(norm, oldPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
l.SourcePath = newSource
|
||||
l.Path = newPath
|
||||
suffix := strings.TrimPrefix(norm, oldPath)
|
||||
l.Path = strings.TrimSuffix(newPath+suffix, "/")
|
||||
if shareWasDir {
|
||||
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
}
|
||||
|
||||
if err := s.back.Save(l); err != nil {
|
||||
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
|
||||
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testutil.QuietLogging()
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,32 @@ package sqldb
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
)
|
||||
|
||||
// settingAccessRulesLowercaseMigrated marks that access rule paths were normalized to lowercase.
|
||||
const settingAccessRulesLowercaseMigrated = "access_rules_lowercase_migrated"
|
||||
|
||||
// AccessRulesLowercaseMigrated reports whether the one-time lowercase migration has completed.
|
||||
func (s *SQLStore) AccessRulesLowercaseMigrated() (bool, error) {
|
||||
_, err := s.GetSetting(settingAccessRulesLowercaseMigrated)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetAccessRulesLowercaseMigrated records that the lowercase migration finished (empty DBs too).
|
||||
func (s *SQLStore) SetAccessRulesLowercaseMigrated() error {
|
||||
return s.SaveSetting(settingAccessRulesLowercaseMigrated, true)
|
||||
}
|
||||
|
||||
// Access rules SQL operations
|
||||
|
||||
// GetAccessRulesBySource retrieves all access rules for a source
|
||||
@@ -121,6 +142,42 @@ func (s *SQLStore) SaveAccessRule(source, path string, rule *access.AccessRule)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateAccessRulePathsLowercase rewrites rule paths to lowercase in one transaction.
|
||||
func (s *SQLStore) MigrateAccessRulePathsLowercase(source string, oldPaths []string, newRules access.RuleMap) error {
|
||||
tx, err := s.BeginTx()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin access rule migration: %w", err)
|
||||
}
|
||||
rollback := func(cause error) error {
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
return fmt.Errorf("%w (rollback also failed: %v)", cause, rbErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
for path, rule := range newRules {
|
||||
ruleDataJSON, err := json.Marshal(rule)
|
||||
if err != nil {
|
||||
return rollback(fmt.Errorf("failed to marshal rule data: %w", err))
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT OR REPLACE INTO access_rules (source, path, rule_data) VALUES (?, ?, ?)`, source, path, ruleDataJSON); err != nil {
|
||||
return rollback(fmt.Errorf("failed to save access rule: %w", err))
|
||||
}
|
||||
}
|
||||
for _, old := range oldPaths {
|
||||
if _, kept := newRules[old]; kept {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM access_rules WHERE source = ? AND path = ?`, source, old); err != nil {
|
||||
return rollback(fmt.Errorf("failed to delete access rule: %w", err))
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit access rule migration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAccessRule deletes an access rule
|
||||
func (s *SQLStore) DeleteAccessRule(source, path string) error {
|
||||
query := `DELETE FROM access_rules WHERE source = ? AND path = ?`
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
)
|
||||
|
||||
// The lowercase marker is set by StartupMigrateAccessRuleKeys after all
|
||||
// sources migrate, not inside the per-source migration transaction.
|
||||
func TestMigrateAccessRulePathsLowercaseRewritesPaths(t *testing.T) {
|
||||
store, _, err := NewSQLStore(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
rule := &access.AccessRule{
|
||||
Allow: access.RuleSet{Users: map[string]struct{}{"bob": {}}},
|
||||
}
|
||||
if err = store.SaveAccessRule("src", "/Old/", rule); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newRules := access.RuleMap{
|
||||
"/old/": rule,
|
||||
}
|
||||
if err = store.MigrateAccessRulePathsLowercase("src", []string{"/Old/"}, newRules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rules, err := store.GetAccessRulesBySource("src")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rules["/old/"] == nil {
|
||||
t.Fatal("expected lowercased rule row")
|
||||
}
|
||||
if rules["/Old/"] != nil {
|
||||
t.Fatal("expected old rule path to be deleted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GlobalPermissionsFieldMask records which global permission JSON keys were present
|
||||
// on a create-user request (admin, api, share, realtime). Omitted keys should receive
|
||||
// userDefaults; present keys keep the caller's value, including explicit false.
|
||||
type GlobalPermissionsFieldMask struct {
|
||||
Admin bool
|
||||
Api bool
|
||||
Share bool
|
||||
Realtime bool
|
||||
}
|
||||
|
||||
// Any reports whether at least one global permission key was explicitly present.
|
||||
func (m GlobalPermissionsFieldMask) Any() bool {
|
||||
return m.Admin || m.Api || m.Share || m.Realtime
|
||||
}
|
||||
|
||||
// GlobalPermissionsFieldMaskFromJSON builds a mask from a permissions object JSON value.
|
||||
// A nil or JSON null value yields an empty mask (no keys explicitly set).
|
||||
func GlobalPermissionsFieldMaskFromJSON(raw json.RawMessage) GlobalPermissionsFieldMask {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return GlobalPermissionsFieldMask{}
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return GlobalPermissionsFieldMask{}
|
||||
}
|
||||
var mask GlobalPermissionsFieldMask
|
||||
for key := range obj {
|
||||
switch strings.ToLower(key) {
|
||||
case "admin":
|
||||
mask.Admin = true
|
||||
case "api":
|
||||
mask.Api = true
|
||||
case "share":
|
||||
mask.Share = true
|
||||
case "realtime":
|
||||
mask.Realtime = true
|
||||
}
|
||||
}
|
||||
return mask
|
||||
}
|
||||
|
||||
// MergeGlobalPermissionsOnCreate combines defaults with caller-supplied global permissions.
|
||||
// When mask is empty, any true global flag on requested preserves the full requested struct
|
||||
// (programmatic create paths). Otherwise only masked fields override defaults.
|
||||
func MergeGlobalPermissionsOnCreate(defaults, requested Permissions, mask GlobalPermissionsFieldMask) Permissions {
|
||||
if !mask.Any() {
|
||||
if requested.Admin || requested.Api || requested.Share || requested.Realtime {
|
||||
return requested
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
out := defaults
|
||||
if mask.Admin {
|
||||
out.Admin = requested.Admin
|
||||
}
|
||||
if mask.Api {
|
||||
out.Api = requested.Api
|
||||
}
|
||||
if mask.Share {
|
||||
out.Share = requested.Share
|
||||
}
|
||||
if mask.Realtime {
|
||||
out.Realtime = requested.Realtime
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGlobalPermissionsFieldMaskFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want GlobalPermissionsFieldMask
|
||||
}{
|
||||
{name: "empty object", raw: `{}`, want: GlobalPermissionsFieldMask{}},
|
||||
{name: "null", raw: `null`, want: GlobalPermissionsFieldMask{}},
|
||||
{name: "admin only", raw: `{"admin":true}`, want: GlobalPermissionsFieldMask{Admin: true}},
|
||||
{name: "partial mixed case", raw: `{"Share":false,"api":true}`, want: GlobalPermissionsFieldMask{Api: true, Share: true}},
|
||||
{name: "explicit false still present", raw: `{"share":false}`, want: GlobalPermissionsFieldMask{Share: true}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := GlobalPermissionsFieldMaskFromJSON(json.RawMessage(tc.raw))
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGlobalPermissionsOnCreate(t *testing.T) {
|
||||
defaults := Permissions{Admin: false, Api: true, Share: true, Realtime: true}
|
||||
|
||||
t.Run("omitted permissions keep defaults", func(t *testing.T) {
|
||||
requested := Permissions{}
|
||||
got := MergeGlobalPermissionsOnCreate(defaults, requested, GlobalPermissionsFieldMask{})
|
||||
if got != defaults {
|
||||
t.Fatalf("got %+v, want defaults %+v", got, defaults)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial mask merges only present keys", func(t *testing.T) {
|
||||
requested := Permissions{Admin: true, Api: false}
|
||||
mask := GlobalPermissionsFieldMask{Admin: true, Api: true}
|
||||
got := MergeGlobalPermissionsOnCreate(defaults, requested, mask)
|
||||
want := Permissions{Admin: true, Api: false, Share: true, Realtime: true}
|
||||
if got != want {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit false on masked field", func(t *testing.T) {
|
||||
requested := Permissions{Share: false}
|
||||
mask := GlobalPermissionsFieldMask{Share: true}
|
||||
got := MergeGlobalPermissionsOnCreate(defaults, requested, mask)
|
||||
want := Permissions{Admin: false, Api: true, Share: false, Realtime: true}
|
||||
if got != want {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("programmatic all true without mask", func(t *testing.T) {
|
||||
requested := Permissions{Admin: true, Api: true, Share: true, Realtime: true}
|
||||
got := MergeGlobalPermissionsOnCreate(defaults, requested, GlobalPermissionsFieldMask{})
|
||||
if got != requested {
|
||||
t.Fatalf("got %+v, want %+v", got, requested)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,68 @@
|
||||
package users
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPermissionsUnmarshalJSONMarksConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var p Permissions
|
||||
if err := json.Unmarshal([]byte(`{"admin":false,"share":false,"api":false,"realtime":false}`), &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.Configured {
|
||||
t.Fatal("expected Configured after unmarshaling an explicit permissions object")
|
||||
}
|
||||
if p.Admin || p.Share || p.Api || p.Realtime {
|
||||
t.Fatalf("expected all-false permissions, got %+v", p)
|
||||
}
|
||||
|
||||
var u struct {
|
||||
Permissions Permissions `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{}`), &u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Permissions.Configured {
|
||||
t.Fatal("omitted permissions must not set Configured")
|
||||
}
|
||||
|
||||
var nullWrap struct {
|
||||
Permissions Permissions `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"permissions":null}`), &nullWrap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nullWrap.Permissions.Configured {
|
||||
t.Fatal("permissions null must not set Configured")
|
||||
}
|
||||
if nullWrap.Permissions.GlobalPermissionsFieldMaskFromPermissions().Any() {
|
||||
t.Fatal("permissions null must not set global key mask")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionsUnmarshalJSONPartialKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var u struct {
|
||||
Permissions Permissions `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"permissions":{"admin":true}}`), &u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !u.Permissions.Configured {
|
||||
t.Fatal("expected Configured for explicit permissions object")
|
||||
}
|
||||
mask := u.Permissions.GlobalPermissionsFieldMaskFromPermissions()
|
||||
if !mask.Admin || mask.Api || mask.Share || mask.Realtime {
|
||||
t.Fatalf("mask = %+v, want admin only", mask)
|
||||
}
|
||||
if !u.Permissions.Admin {
|
||||
t.Fatal("expected admin true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceFilePermissionsIsUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -113,15 +114,65 @@ type MinimalAuthToken struct {
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Api bool `json:"api"` // allow api access
|
||||
Admin bool `json:"admin"` // allow admin access
|
||||
Modify bool `json:"modify,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Share bool `json:"share"` // allow sharing files
|
||||
Realtime bool `json:"realtime"` // allow realtime updates
|
||||
Delete bool `json:"delete,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Create bool `json:"create,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Download bool `json:"download,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
View bool `json:"view,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Api bool `json:"api"` // allow api access
|
||||
Admin bool `json:"admin"` // allow admin access
|
||||
Modify bool `json:"modify,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Share bool `json:"share"` // allow sharing files
|
||||
Realtime bool `json:"realtime"` // allow realtime updates
|
||||
Delete bool `json:"delete,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Create bool `json:"create,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Download bool `json:"download,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
View bool `json:"view,omitempty"` // deprecated: legacy user migration only; not used for API tokens
|
||||
Configured bool `json:"-"` // true when explicitly set on create (allows intentional deny-all); not persisted or API-visible
|
||||
|
||||
PresentGlobalKeys GlobalPermissionsFieldMask `json:"-"` // JSON keys present on create-user permissions object
|
||||
}
|
||||
|
||||
// MarkPermissionsConfigured marks account permissions as explicitly set (including all-false).
|
||||
func MarkPermissionsConfigured(p Permissions) Permissions {
|
||||
p.Configured = true
|
||||
return p
|
||||
}
|
||||
|
||||
// GlobalPermissionsFieldMaskFromPermissions returns keys recorded during Permissions JSON decode.
|
||||
func (p Permissions) GlobalPermissionsFieldMaskFromPermissions() GlobalPermissionsFieldMask {
|
||||
return p.PresentGlobalKeys
|
||||
}
|
||||
|
||||
// UnmarshalJSON sets Configured so an explicit all-false permissions object is distinct from omission.
|
||||
// JSON null and absent permissions do not set Configured.
|
||||
func (p *Permissions) UnmarshalJSON(data []byte) error {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
*p = Permissions{}
|
||||
return nil
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
return err
|
||||
}
|
||||
type plain Permissions
|
||||
var tmp plain
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = Permissions(tmp)
|
||||
p.Configured = true
|
||||
p.PresentGlobalKeys = GlobalPermissionsFieldMaskFromJSON(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveGlobalPermissionsMask combines the caller-supplied field mask and the key
|
||||
// presence recorded by Permissions.UnmarshalJSON.
|
||||
func (u *User) EffectiveGlobalPermissionsMask() GlobalPermissionsFieldMask {
|
||||
fromPerms := u.Permissions.GlobalPermissionsFieldMaskFromPermissions()
|
||||
m := u.PermissionsFieldMask
|
||||
return GlobalPermissionsFieldMask{
|
||||
Admin: m.Admin || fromPerms.Admin,
|
||||
Api: m.Api || fromPerms.Api,
|
||||
Share: m.Share || fromPerms.Share,
|
||||
Realtime: m.Realtime || fromPerms.Realtime,
|
||||
}
|
||||
}
|
||||
|
||||
// SourceFilePermissions holds per-source file operation permissions (v4+).
|
||||
@@ -206,6 +257,10 @@ type UserLegacyFields struct {
|
||||
type User struct {
|
||||
FrontendUser
|
||||
ID uint64 `json:"id,omitempty"`
|
||||
// PermissionsFieldMask marks global permission keys a programmatic create caller
|
||||
// explicitly supplied; not persisted. JSON create requests record key presence on
|
||||
// Permissions.PresentGlobalKeys during decode instead.
|
||||
PermissionsFieldMask GlobalPermissionsFieldMask `json:"-"`
|
||||
// BackendScopes is the authoritative, persisted access list (SourceScope.Name = backend source path).
|
||||
// SQLite stores this inside user_data JSON under the key "scopes" (see sqldb.UserData).
|
||||
BackendScopes []BackendScope `json:"backendScopes,omitempty"`
|
||||
|
||||
@@ -41,15 +41,13 @@ func handleUserEvents() {
|
||||
for ue := range userEventChan {
|
||||
for _, user := range ue.users {
|
||||
userClientsMu.RLock()
|
||||
conns := userClients[user]
|
||||
userClientsMu.RUnlock()
|
||||
|
||||
for _, ch := range conns {
|
||||
for _, ch := range userClients[user] {
|
||||
select {
|
||||
case ch <- ue.event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
userClientsMu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,28 +133,14 @@ func DebouncedBroadcast(eventType, message string) {
|
||||
func handleSourceUpdates() {
|
||||
for update := range sourceUpdateChan {
|
||||
sourceClientsMu.RLock()
|
||||
clients := sourceClients[update.source]
|
||||
clientCount := len(clients)
|
||||
sourceClientsMu.RUnlock()
|
||||
|
||||
if clientCount == 0 {
|
||||
// No clients registered for this source - this is normal if no one is connected
|
||||
continue
|
||||
}
|
||||
|
||||
sentCount := 0
|
||||
for ch := range clients {
|
||||
for ch := range sourceClients[update.source] {
|
||||
select {
|
||||
case ch <- update.event:
|
||||
sentCount++
|
||||
default:
|
||||
// Channel full, message dropped
|
||||
}
|
||||
}
|
||||
// Log if we have clients but couldn't send to all
|
||||
//if sentCount < clientCount {
|
||||
// // Some messages were dropped due to full channels
|
||||
//}
|
||||
sourceClientsMu.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConcurrentRegisterUnregisterSourceUpdates(t *testing.T) {
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
ch := Register("stress-user", []string{"stress-source"})
|
||||
Unregister("stress-user", ch)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < 200; i++ {
|
||||
SendSourceUpdate("stress-source", "tick")
|
||||
}
|
||||
close(stop)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestConcurrentSSEClientLifecycleRace(t *testing.T) {
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
user := "race-user-" + string(rune('a'+id))
|
||||
source := "race-source-" + string(rune('a'+id%2))
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
ch := Register(user, []string{source, "shared-source"})
|
||||
SendSourceUpdate(source, "tick")
|
||||
SendSourceUpdate("shared-source", "tick")
|
||||
SendToUsers("ping", "msg", []string{user})
|
||||
Unregister(user, ch)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
SendSourceUpdate("shared-source", "broadcast")
|
||||
SendToUsers("broadcast", "all", []string{"race-user-a", "race-user-b"})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
Shutdown()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/image/bmp"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testutil.QuietLogging()
|
||||
// Ensure fileutils permissions are set (needed by NewPreviewGenerator)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
|
||||
@@ -21,6 +21,8 @@ type UploadContext struct {
|
||||
SessionID string
|
||||
TotalSize int64
|
||||
HasKnownSize bool
|
||||
PriorFileSize int64
|
||||
HasPriorFileSize bool
|
||||
}
|
||||
|
||||
// CheckUploadLength verifies size is known when quotas apply.
|
||||
@@ -90,7 +92,9 @@ func uploadCommitDelta(ctx UploadContext) int64 {
|
||||
return 0
|
||||
}
|
||||
oldSize := int64(0)
|
||||
if ctx.DestRealPath != "" {
|
||||
if ctx.HasPriorFileSize {
|
||||
oldSize = ctx.PriorFileSize
|
||||
} else if ctx.DestRealPath != "" {
|
||||
if stat, err := os.Stat(ctx.DestRealPath); err == nil && !stat.IsDir() {
|
||||
oldSize = stat.Size()
|
||||
}
|
||||
|
||||
@@ -1,49 +1,27 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
import "testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestUploadCommitDelta_shrinkReturnsNegative(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "file.bin")
|
||||
if err := os.WriteFile(dest, make([]byte, 100), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func TestUploadCommitDeltaTreatsMissingDestAsZeroPrior(t *testing.T) {
|
||||
ctx := UploadContext{
|
||||
Principal: &users.User{},
|
||||
DestRealPath: dest,
|
||||
TotalSize: 50,
|
||||
HasKnownSize: true,
|
||||
TotalSize: 50,
|
||||
HasKnownSize: true,
|
||||
HasPriorFileSize: true,
|
||||
PriorFileSize: 0,
|
||||
}
|
||||
if got := UploadCommitDelta(ctx); got != -50 {
|
||||
t.Fatalf("UploadCommitDelta shrink: got %d want -50", got)
|
||||
}
|
||||
if got := uploadReserveDelta(ctx); got != 0 {
|
||||
t.Fatalf("uploadReserveDelta shrink: got %d want 0", got)
|
||||
if delta := uploadCommitDelta(ctx); delta != 50 {
|
||||
t.Fatalf("delta = %d, want 50", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadCommitDelta_growthPositive(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dest := filepath.Join(dir, "file.bin")
|
||||
if err := os.WriteFile(dest, make([]byte, 40), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
func TestUploadCommitDeltaUsesPriorFileSize(t *testing.T) {
|
||||
ctx := UploadContext{
|
||||
Principal: &users.User{},
|
||||
DestRealPath: dest,
|
||||
TotalSize: 100,
|
||||
HasKnownSize: true,
|
||||
TotalSize: 100,
|
||||
HasKnownSize: true,
|
||||
HasPriorFileSize: true,
|
||||
PriorFileSize: 40,
|
||||
}
|
||||
if got := UploadCommitDelta(ctx); got != 60 {
|
||||
t.Fatalf("UploadCommitDelta growth: got %d want 60", got)
|
||||
}
|
||||
if got := uploadReserveDelta(ctx); got != 60 {
|
||||
t.Fatalf("uploadReserveDelta growth: got %d want 60", got)
|
||||
if delta := uploadCommitDelta(ctx); delta != 60 {
|
||||
t.Fatalf("delta = %d, want 60", delta)
|
||||
}
|
||||
}
|
||||
@@ -535,6 +535,12 @@ func ApplicableFolderQuotas(sourcePath, destIndexPath string, principalUserID ui
|
||||
return out
|
||||
}
|
||||
|
||||
type reserveApplyPlan struct {
|
||||
chk QuotaReserveCheck
|
||||
existingIdx int
|
||||
counterDelta int64
|
||||
}
|
||||
|
||||
// ReserveQuota increases reserved bytes for applicable quotas.
|
||||
func ReserveQuota(sessionID string, checks []QuotaReserveCheck) error {
|
||||
if sessionID == "" || len(checks) == 0 {
|
||||
@@ -543,19 +549,57 @@ func ReserveQuota(sessionID string, checks []QuotaReserveCheck) error {
|
||||
quotasMux.Lock()
|
||||
defer quotasMux.Unlock()
|
||||
|
||||
plans := make([]reserveApplyPlan, 0, len(checks))
|
||||
for _, chk := range checks {
|
||||
if chk.LimitBytes <= 0 || chk.DeltaBytes <= 0 {
|
||||
continue
|
||||
}
|
||||
reserved := sumSessionReservedLocked(chk.QuotaID)
|
||||
if chk.UsedBytes+reserved+chk.DeltaBytes > chk.LimitBytes {
|
||||
existingIdx := -1
|
||||
var existingBytes int64
|
||||
for i, r := range reservationsBySession[sessionID] {
|
||||
if r.QuotaID == chk.QuotaID {
|
||||
existingIdx = i
|
||||
existingBytes = r.Bytes
|
||||
break
|
||||
}
|
||||
}
|
||||
if existingIdx >= 0 && existingBytes == chk.DeltaBytes {
|
||||
continue
|
||||
}
|
||||
reservedOther := sumSessionReservedLocked(chk.QuotaID)
|
||||
if existingIdx >= 0 {
|
||||
reservedOther -= existingBytes
|
||||
}
|
||||
if chk.UsedBytes+reservedOther+chk.DeltaBytes > chk.LimitBytes {
|
||||
return fmt.Errorf("quota exceeded for %s", chk.QuotaID)
|
||||
}
|
||||
counterDelta := chk.DeltaBytes
|
||||
if existingIdx >= 0 {
|
||||
counterDelta = chk.DeltaBytes - existingBytes
|
||||
}
|
||||
plans = append(plans, reserveApplyPlan{
|
||||
chk: chk,
|
||||
existingIdx: existingIdx,
|
||||
counterDelta: counterDelta,
|
||||
})
|
||||
}
|
||||
|
||||
for _, plan := range plans {
|
||||
chk := plan.chk
|
||||
if chk.Meter == quota.MeterAccounted || chk.Kind == "share" {
|
||||
if err := ensureQuotaCounterMemLocked(chk.QuotaID); err != nil {
|
||||
return err
|
||||
}
|
||||
quotaCounters[chk.QuotaID].ReservedBytes += chk.DeltaBytes
|
||||
}
|
||||
}
|
||||
for _, plan := range plans {
|
||||
chk := plan.chk
|
||||
if chk.Meter == quota.MeterAccounted || chk.Kind == "share" {
|
||||
quotaCounters[chk.QuotaID].ReservedBytes += plan.counterDelta
|
||||
}
|
||||
if plan.existingIdx >= 0 {
|
||||
reservationsBySession[sessionID][plan.existingIdx].Bytes = chk.DeltaBytes
|
||||
continue
|
||||
}
|
||||
reservationsBySession[sessionID] = append(reservationsBySession[sessionID], quotaReservation{
|
||||
QuotaID: chk.QuotaID,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
|
||||
)
|
||||
|
||||
func withCleanQuotaReservations(t *testing.T) {
|
||||
oldReservations := reservationsBySession
|
||||
oldCounters := quotaCounters
|
||||
initQuotaMaps()
|
||||
t.Cleanup(func() {
|
||||
quotasMux.Lock()
|
||||
reservationsBySession = oldReservations
|
||||
quotaCounters = oldCounters
|
||||
quotasMux.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestReserveQuota_idempotentReplaceUsesNewTotalNotIncrementTwice(t *testing.T) {
|
||||
withCleanQuotaReservations(t)
|
||||
|
||||
const quotaID = "folder-q1"
|
||||
check := func(delta int64) QuotaReserveCheck {
|
||||
return QuotaReserveCheck{
|
||||
QuotaID: quotaID,
|
||||
LimitBytes: 100,
|
||||
UsedBytes: 0,
|
||||
DeltaBytes: delta,
|
||||
}
|
||||
}
|
||||
|
||||
if err := ReserveQuota("upload-session", []QuotaReserveCheck{check(40)}); err != nil {
|
||||
t.Fatalf("initial reserve: %v", err)
|
||||
}
|
||||
if got := SessionReservedForQuota(quotaID); got != 40 {
|
||||
t.Fatalf("after 40: reserved=%d", got)
|
||||
}
|
||||
|
||||
if err := ReserveQuota("upload-session", []QuotaReserveCheck{check(70)}); err != nil {
|
||||
t.Fatalf("replace with 70: %v", err)
|
||||
}
|
||||
if got := SessionReservedForQuota(quotaID); got != 70 {
|
||||
t.Fatalf("after 70: reserved=%d want 70", got)
|
||||
}
|
||||
|
||||
if err := ReserveQuota("upload-session", []QuotaReserveCheck{check(101)}); err == nil {
|
||||
t.Fatal("expected quota exceeded when replacing with 101")
|
||||
}
|
||||
if got := SessionReservedForQuota(quotaID); got != 70 {
|
||||
t.Fatalf("after failed 101: reserved=%d want 70 unchanged", got)
|
||||
}
|
||||
|
||||
if err := ReserveQuota("upload-session", []QuotaReserveCheck{check(100)}); err != nil {
|
||||
t.Fatalf("replace with 100 at limit: %v", err)
|
||||
}
|
||||
if got := SessionReservedForQuota(quotaID); got != 100 {
|
||||
t.Fatalf("after 100: reserved=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveQuota_multiCheckAtomicOnFailure(t *testing.T) {
|
||||
withCleanQuotaReservations(t)
|
||||
|
||||
ok := QuotaReserveCheck{QuotaID: "q-ok", LimitBytes: 1000, UsedBytes: 0, DeltaBytes: 200}
|
||||
if err := ReserveQuota("sess", []QuotaReserveCheck{ok}); err != nil {
|
||||
t.Fatalf("setup reserve: %v", err)
|
||||
}
|
||||
if got := SessionReservedForQuota("q-ok"); got != 200 {
|
||||
t.Fatalf("setup reserved=%d", got)
|
||||
}
|
||||
|
||||
tight := QuotaReserveCheck{QuotaID: "q-tight", LimitBytes: 50, UsedBytes: 0, DeltaBytes: 60}
|
||||
batch := []QuotaReserveCheck{
|
||||
{QuotaID: "q-ok", LimitBytes: 1000, UsedBytes: 0, DeltaBytes: 500},
|
||||
tight,
|
||||
}
|
||||
if err := ReserveQuota("sess", batch); err == nil {
|
||||
t.Fatal("expected second quota in batch to fail")
|
||||
}
|
||||
if got := SessionReservedForQuota("q-ok"); got != 200 {
|
||||
t.Fatalf("first quota must be unchanged after failed batch, reserved=%d", got)
|
||||
}
|
||||
if got := SessionReservedForQuota("q-tight"); got != 0 {
|
||||
t.Fatalf("failed quota must not be reserved, got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Accounted meters run counter ensure before any reservation mutation; a failed batch must
|
||||
// leave session reservations unchanged (two-pass ReserveQuota).
|
||||
func TestReserveQuota_accountedBatchAtomicOnFailure(t *testing.T) {
|
||||
withCleanQuotaReservations(t)
|
||||
if sqlDb == nil {
|
||||
t.Skip("requires initialized SQL store for accounted quota counters")
|
||||
}
|
||||
|
||||
first := QuotaReserveCheck{
|
||||
QuotaID: "q-accounted-a",
|
||||
Kind: "share",
|
||||
Meter: quota.MeterAccounted,
|
||||
LimitBytes: 1000,
|
||||
UsedBytes: 0,
|
||||
DeltaBytes: 100,
|
||||
}
|
||||
if err := ReserveQuota("sess-acct", []QuotaReserveCheck{first}); err != nil {
|
||||
t.Fatalf("setup reserve: %v", err)
|
||||
}
|
||||
if got := SessionReservedForQuota("q-accounted-a"); got != 100 {
|
||||
t.Fatalf("setup reserved=%d", got)
|
||||
}
|
||||
|
||||
batch := []QuotaReserveCheck{
|
||||
{QuotaID: "q-accounted-a", Kind: "share", Meter: quota.MeterAccounted, LimitBytes: 1000, UsedBytes: 0, DeltaBytes: 500},
|
||||
{QuotaID: "q-accounted-b", Kind: "share", Meter: quota.MeterAccounted, LimitBytes: 50, UsedBytes: 0, DeltaBytes: 60},
|
||||
}
|
||||
if err := ReserveQuota("sess-acct", batch); err == nil {
|
||||
t.Fatal("expected second accounted quota in batch to fail")
|
||||
}
|
||||
if got := SessionReservedForQuota("q-accounted-a"); got != 100 {
|
||||
t.Fatalf("first accounted reservation must be unchanged after failed batch, reserved=%d", got)
|
||||
}
|
||||
if got := SessionReservedForQuota("q-accounted-b"); got != 0 {
|
||||
t.Fatalf("failed accounted quota must not be reserved, got=%d", got)
|
||||
}
|
||||
}
|
||||
@@ -348,14 +348,19 @@ func UpdateSharesForMovedResource(oldSource, oldPath, newSource, newPath string)
|
||||
continue
|
||||
}
|
||||
norm := utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
if !strings.Contains(norm, oldPath) {
|
||||
if !strings.HasPrefix(norm, oldPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
removeShareFromPathIndexLocked(l.SourcePath, l.Path, l.Hash)
|
||||
|
||||
shareWasDir := strings.HasSuffix(l.Path, "/")
|
||||
l.SourcePath = newSource
|
||||
l.Path = newPath
|
||||
suffix := strings.TrimPrefix(norm, oldPath)
|
||||
l.Path = strings.TrimSuffix(newPath+suffix, "/")
|
||||
if shareWasDir {
|
||||
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
}
|
||||
|
||||
if err := sqlDb.SaveShare(l); err != nil {
|
||||
logger.Error("failed to save updated share after resource move", "hash", l.Hash, "error", err)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
)
|
||||
|
||||
func rewriteSharePathAfterMove(sharePath, oldPath, newPath string) string {
|
||||
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
newPath = utils.AddTrailingSlashIfNotExists(newPath)
|
||||
norm := utils.AddTrailingSlashIfNotExists(sharePath)
|
||||
if !strings.HasPrefix(norm, oldPath) {
|
||||
return sharePath
|
||||
}
|
||||
suffix := strings.TrimPrefix(norm, oldPath)
|
||||
shareWasDir := strings.HasSuffix(sharePath, "/")
|
||||
updated := strings.TrimSuffix(newPath+suffix, "/")
|
||||
if shareWasDir {
|
||||
updated = utils.AddTrailingSlashIfNotExists(updated)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func TestRewriteSharePathAfterMove(t *testing.T) {
|
||||
got := rewriteSharePathAfterMove("/photos/2024/one.jpg", "/photos/", "/archive/photos/")
|
||||
want := "/archive/photos/2024/one.jpg"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
if rewriteSharePathAfterMove("/backup/photos/", "/photos/", "/archive/photos/") != "/backup/photos/" {
|
||||
t.Fatal("substring match must not retarget unrelated share")
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,16 @@ func initialize(dbPath string) (bool, error) {
|
||||
|
||||
newAccessDb.SetSQLStore(sqlDb)
|
||||
|
||||
accessRulesLowercased, err := sqlDb.AccessRulesLowercaseMigrated()
|
||||
if err != nil {
|
||||
return existingDb, fmt.Errorf("failed to read access rules migration marker: %w", err)
|
||||
}
|
||||
if !accessRulesLowercased {
|
||||
if migErr := newAccessDb.StartupMigrateAccessRuleKeys(); migErr != nil {
|
||||
return existingDb, fmt.Errorf("failed to migrate access rule paths: %w", migErr)
|
||||
}
|
||||
}
|
||||
|
||||
usersMux.Lock()
|
||||
sharesMux.Lock()
|
||||
indexMux.Lock()
|
||||
|
||||
@@ -273,6 +273,23 @@ func EffectiveUserDefaults() settings.UserDefaults {
|
||||
return userDefaultsDefault
|
||||
}
|
||||
|
||||
// SetEffectiveUserDefaultsForTest replaces in-memory universal defaults (tests only).
|
||||
func SetEffectiveUserDefaultsForTest(t interface {
|
||||
Helper()
|
||||
Cleanup(func())
|
||||
}, d settings.UserDefaults) {
|
||||
t.Helper()
|
||||
userDefaultsMu.Lock()
|
||||
previous := userDefaultsDefault
|
||||
userDefaultsDefault = d
|
||||
userDefaultsMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
userDefaultsMu.Lock()
|
||||
userDefaultsDefault = previous
|
||||
userDefaultsMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyUserDefaults applies persisted defaults to a user, including sidebar link and tool access defaults.
|
||||
func ApplyUserDefaults(u *users.User) {
|
||||
applyUserSettingsDefaults(u)
|
||||
|
||||
@@ -19,6 +19,25 @@ import (
|
||||
|
||||
// User operations
|
||||
|
||||
// backendScopeKey identifies a scope by its full identity for permission restore: scopes
|
||||
// may share a source path while carrying different permissions per sub-scope.
|
||||
type backendScopeKey struct {
|
||||
path string
|
||||
scope string
|
||||
}
|
||||
|
||||
// backendScopeKeyFor normalizes an empty scope the same way MergeDefaultEnabledBackendScopes
|
||||
// does (source defaultUserScope) so snapshot keys still match after defaults are applied.
|
||||
func backendScopeKeyFor(scope users.BackendScope) backendScopeKey {
|
||||
s := scope.Scope
|
||||
if s == "" {
|
||||
if src, ok := settings.Config.Server.SourceMap[scope.Path]; ok && src != nil {
|
||||
s = src.Config.DefaultUserScope
|
||||
}
|
||||
}
|
||||
return backendScopeKey{path: scope.Path, scope: s}
|
||||
}
|
||||
|
||||
// copyBackendSourcePermissions returns a shallow copy of the per-source permissions map.
|
||||
func copyBackendSourcePermissions(m map[string]users.SourceFilePermissions) map[string]users.SourceFilePermissions {
|
||||
if m == nil {
|
||||
@@ -203,23 +222,68 @@ func CreateUser(user *users.User, plaintextPassword string) error {
|
||||
user.Password = hashedPassword
|
||||
}
|
||||
|
||||
// Merge global permissions before converting API scopes: the final Admin value
|
||||
// (including account defaults when the request omits admin) decides which source
|
||||
// permissions applyScopesFromAPI assigns to scopes that omit permissions.
|
||||
preserveRequirePasswordChange := user.RequirePasswordChange
|
||||
requestedPermissions := user.Permissions
|
||||
globalMask := user.EffectiveGlobalPermissionsMask()
|
||||
// Configured distinguishes an explicit all-false permissions object from omission
|
||||
// (zero value). Any set bit still counts so Go callers that set Admin without
|
||||
// MarkPermissionsConfigured (e.g. OIDC admin promotion) keep working.
|
||||
permissionsRequested := globalMask.Any() || requestedPermissions.Configured ||
|
||||
requestedPermissions.Admin || requestedPermissions.Share ||
|
||||
requestedPermissions.Api || requestedPermissions.Realtime ||
|
||||
requestedPermissions.HasAnyFilePermission()
|
||||
if globalMask.Any() || !permissionsRequested {
|
||||
defaultPerms := settings.ConvertPermissionsToUsers(EffectiveUserDefaults().Account.Permissions)
|
||||
user.Permissions = users.MergeGlobalPermissionsOnCreate(defaultPerms, requestedPermissions, globalMask)
|
||||
} else {
|
||||
user.Permissions = requestedPermissions
|
||||
}
|
||||
|
||||
// Incoming API "scopes" → BackendScopes; FrontendScopes must not remain on the persisted user.
|
||||
if err := applyScopesFromAPI(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If still no BackendScopes (omitted or invalid API names), same defaults as ApplyUserDefaults.
|
||||
preserveRequirePasswordChange := user.RequirePasswordChange
|
||||
applyUserSettingsDefaults(user)
|
||||
// Snapshot caller-supplied scope permissions by full scope identity so the defaults
|
||||
// application below cannot collapse scopes sharing a source path.
|
||||
requestedScopePermissions := make(map[backendScopeKey]users.SourceFilePermissions, len(user.BackendScopes))
|
||||
for _, scope := range user.BackendScopes {
|
||||
if !scope.Permissions.IsUnset() {
|
||||
requestedScopePermissions[backendScopeKeyFor(scope)] = scope.Permissions
|
||||
}
|
||||
}
|
||||
// Apply profile/scope defaults using the final account permissions (admin flag drives scope defaults).
|
||||
if permissionsRequested {
|
||||
settings.ApplyUserDefaultsFromKeepingAccountPermissions(user, EffectiveUserDefaults())
|
||||
} else {
|
||||
applyUserSettingsDefaults(user)
|
||||
}
|
||||
if preserveRequirePasswordChange {
|
||||
user.RequirePasswordChange = true
|
||||
}
|
||||
user.PermissionsFieldMask = users.GlobalPermissionsFieldMask{}
|
||||
for i, scope := range user.BackendScopes {
|
||||
if perms, ok := requestedScopePermissions[backendScopeKeyFor(scope)]; ok {
|
||||
user.BackendScopes[i].Permissions = perms
|
||||
if user.BackendSourcePermissions == nil {
|
||||
user.BackendSourcePermissions = make(map[string]users.SourceFilePermissions)
|
||||
}
|
||||
user.BackendSourcePermissions[scope.Path] = perms
|
||||
}
|
||||
}
|
||||
defaults := EffectiveUserDefaults()
|
||||
enforced := EffectiveEnforced()
|
||||
settings.ApplyEnforcedDefaultsFrom(user, defaults, enforced)
|
||||
settings.ApplyEnforcedSourcePermissionsFrom(user, GetSourceAccessDefaults(), GetEnforcedSourcePermissions())
|
||||
|
||||
users.SyncBackendSourcePermissionsMap(user)
|
||||
if !user.Permissions.Admin {
|
||||
settings.ExpandBackendScopesForCreateUserDir(user)
|
||||
}
|
||||
|
||||
applySidebarLinkDefaults(user)
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestCreateUserValidateUsername(t *testing.T) {
|
||||
@@ -55,6 +57,76 @@ func TestCreateUserValidateUsername(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserCreateUserDirRespectsFinalAdminPermission(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.Initialize("../../../_docker/src/noauth/backend/config.yaml")
|
||||
settings.Env.IsPlaywright = true
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
if _, err := Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Close() })
|
||||
|
||||
root := t.TempDir()
|
||||
source := &settings.Source{
|
||||
Path: root,
|
||||
Config: settings.SourceConfig{
|
||||
CreateUserDir: true,
|
||||
},
|
||||
}
|
||||
settings.Config.Server.Sources = []*settings.Source{source}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{root: source}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
admin bool
|
||||
username string
|
||||
wantScope string
|
||||
wantDir bool
|
||||
}{
|
||||
{name: "admin keeps source root", admin: true, username: "new-admin", wantScope: "/", wantDir: false},
|
||||
{name: "non-admin gets personal directory", admin: false, username: "new-user", wantScope: "/new-user", wantDir: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := EffectiveUserDefaults()
|
||||
d.Account.Permissions.Admin = tc.admin
|
||||
SetEffectiveUserDefaultsForTest(t, d)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: tc.username},
|
||||
BackendScopes: []users.BackendScope{{Path: root, Scope: "/"}},
|
||||
}
|
||||
if err := CreateUser(user, "password"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
saved, err := GetUserByUsername(tc.username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
if saved.Permissions.Admin != tc.admin {
|
||||
t.Fatalf("saved admin=%v, want %v", saved.Permissions.Admin, tc.admin)
|
||||
}
|
||||
if got := saved.BackendScopes[0].Scope; got != tc.wantScope {
|
||||
t.Fatalf("saved scope=%q, want %q", got, tc.wantScope)
|
||||
}
|
||||
|
||||
if err := files.MakeUserDirs(&saved, true); err != nil {
|
||||
t.Fatalf("MakeUserDirs: %v", err)
|
||||
}
|
||||
_, statErr := os.Stat(filepath.Join(root, tc.username))
|
||||
if tc.wantDir && statErr != nil {
|
||||
t.Fatalf("user directory does not exist: %v", statErr)
|
||||
}
|
||||
if !tc.wantDir && !os.IsNotExist(statErr) {
|
||||
t.Fatalf("user directory stat error=%v, want not-exist", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserPatchPreservesTOTPSecret(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.Initialize("../../../_docker/src/noauth/backend/config.yaml")
|
||||
@@ -259,7 +331,7 @@ func TestUpdateUserClearsTOTPWhenOtpDisabled(t *testing.T) {
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "alice",
|
||||
Username: "alice",
|
||||
OtpEnabled: true,
|
||||
},
|
||||
}
|
||||
@@ -310,11 +382,11 @@ func TestUpdateUserPatchPreservesBackendSourcePermissions(t *testing.T) {
|
||||
scopePerms := users.SourceFilePermissions{View: true, Download: true, Modify: true}
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "alice",
|
||||
Username: "alice",
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{{Path: "/data/a", Scope: "/", Permissions: scopePerms}},
|
||||
Version: users.SourcePermissionsMigrationVersion,
|
||||
Version: users.SourcePermissionsMigrationVersion,
|
||||
}
|
||||
if err = CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -341,3 +413,242 @@ func TestUpdateUserPatchPreservesBackendSourcePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserKeepsRequestedPermissions(t *testing.T) {
|
||||
original := settings.Config
|
||||
t.Cleanup(func() { settings.Config = original })
|
||||
settings.Config = settings.SetDefaults(true)
|
||||
source := &settings.Source{Path: "/data/a", Name: "a", Config: settings.SourceConfig{
|
||||
DefaultEnabled: true,
|
||||
DefaultPermissions: users.SourceFilePermissions{View: true, Download: true, Modify: true, Create: true},
|
||||
}}
|
||||
settings.Config.Server.Sources = []*settings.Source{source}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{source.Path: source}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{source.Name: source}
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
_, err := Initialize(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Close() })
|
||||
|
||||
requested := users.SourceFilePermissions{View: true, Download: true, Delete: true}
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "alice",
|
||||
Permissions: users.Permissions{Admin: true, Share: true, Api: true, Realtime: true},
|
||||
FrontendScopes: []users.FrontendScope{{Name: "a", Scope: "/", Permissions: &requested}},
|
||||
},
|
||||
}
|
||||
if err = CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := GetUserByUsername("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPerms := users.Permissions{Admin: true, Share: true, Api: true, Realtime: true}
|
||||
if loaded.Permissions != wantPerms {
|
||||
t.Fatalf("permissions = %+v, want %+v", loaded.Permissions, wantPerms)
|
||||
}
|
||||
if len(loaded.BackendScopes) == 0 {
|
||||
t.Fatal("expected a scope for the requested source")
|
||||
}
|
||||
got := loaded.BackendScopes[0].Permissions
|
||||
if got.View != requested.View || got.Download != requested.Download || got.Modify != requested.Modify ||
|
||||
got.Create != requested.Create || got.Delete != requested.Delete {
|
||||
t.Fatalf("scope permissions = %+v, want %+v", got, requested)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-admin create with another account flag set must not keep admin-level source file
|
||||
// permissions that applyUserSettingsDefaults would assign while defaults still had Admin.
|
||||
func TestCreateUserNonAdminDoesNotKeepAdminScopePermissions(t *testing.T) {
|
||||
original := settings.Config
|
||||
t.Cleanup(func() { settings.Config = original })
|
||||
settings.Config = settings.SetDefaults(true)
|
||||
sourceDefaults := users.SourceFilePermissions{View: true, Download: true, Modify: true, Create: true}
|
||||
source := &settings.Source{Path: "/data/a", Name: "a", Config: settings.SourceConfig{
|
||||
DefaultEnabled: true,
|
||||
DefaultPermissions: sourceDefaults,
|
||||
}}
|
||||
settings.Config.Server.Sources = []*settings.Source{source}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{source.Path: source}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{source.Name: source}
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
if _, err := Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Close() })
|
||||
|
||||
d := EffectiveUserDefaults()
|
||||
d.Account.Permissions.Admin = true
|
||||
d.Account.Permissions.Share = false
|
||||
SetEffectiveUserDefaultsForTest(t, d)
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "bob",
|
||||
Permissions: users.Permissions{Admin: false, Share: true},
|
||||
},
|
||||
}
|
||||
if err := CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := GetUserByUsername("bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Permissions.Admin {
|
||||
t.Fatal("expected non-admin account permissions")
|
||||
}
|
||||
if !loaded.Permissions.Share {
|
||||
t.Fatal("expected requested share permission to be kept")
|
||||
}
|
||||
if len(loaded.BackendScopes) == 0 {
|
||||
t.Fatal("expected default-enabled source scope")
|
||||
}
|
||||
got := loaded.BackendScopes[0].Permissions
|
||||
adminPerms := settings.AdminSourceFilePermissions()
|
||||
if got.Delete == adminPerms.Delete && got.Modify == adminPerms.Modify && got.Create == adminPerms.Create {
|
||||
t.Fatalf("non-admin kept admin scope permissions: %+v", got)
|
||||
}
|
||||
want := settings.DefaultSourceFilePermissions()
|
||||
if got.View != want.View || got.Download != want.Download || got.Modify != want.Modify ||
|
||||
got.Create != want.Create || got.Delete != want.Delete {
|
||||
t.Fatalf("scope permissions = %+v, want non-admin defaults %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func setupCreateUserDefaultsTest(t *testing.T) {
|
||||
t.Helper()
|
||||
original := settings.Config
|
||||
t.Cleanup(func() { settings.Config = original })
|
||||
settings.Config = settings.SetDefaults(true)
|
||||
settings.Config.UserDefaults.Account.Permissions.Share = true
|
||||
settings.Config.UserDefaults.Account.Permissions.Api = true
|
||||
settings.Config.UserDefaults.Account.Permissions.Realtime = true
|
||||
d := EffectiveUserDefaults()
|
||||
d.Account.Permissions.Share = true
|
||||
d.Account.Permissions.Api = true
|
||||
d.Account.Permissions.Realtime = true
|
||||
SetEffectiveUserDefaultsForTest(t, d)
|
||||
source := &settings.Source{Path: "/data/a", Name: "a", Config: settings.SourceConfig{
|
||||
DefaultEnabled: true,
|
||||
DefaultPermissions: users.SourceFilePermissions{View: true, Download: true},
|
||||
}}
|
||||
settings.Config.Server.Sources = []*settings.Source{source}
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{source.Path: source}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{source.Name: source}
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
if _, err := Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Close() })
|
||||
}
|
||||
|
||||
// Explicit all-false account permissions must not inherit Admin from user defaults.
|
||||
func TestCreateUserAllFalsePermissionsDoesNotInheritAdminDefaults(t *testing.T) {
|
||||
setupCreateUserDefaultsTest(t)
|
||||
|
||||
d := EffectiveUserDefaults()
|
||||
d.Account.Permissions.Admin = true
|
||||
d.Account.Permissions.Share = true
|
||||
d.Account.Permissions.Api = true
|
||||
SetEffectiveUserDefaultsForTest(t, d)
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "carol",
|
||||
Permissions: users.MarkPermissionsConfigured(users.Permissions{}),
|
||||
},
|
||||
}
|
||||
if err := CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := GetUserByUsername("carol")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Permissions.Admin || loaded.Permissions.Share || loaded.Permissions.Api || loaded.Permissions.Realtime {
|
||||
t.Fatalf("expected all-false account permissions, got %+v", loaded.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserAppliesDefaultGlobalPermissionsWhenOmitted(t *testing.T) {
|
||||
setupCreateUserDefaultsTest(t)
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "bob",
|
||||
},
|
||||
}
|
||||
if err := CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := GetUserByUsername("bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := users.Permissions{Share: true, Api: true, Realtime: true}
|
||||
if loaded.Permissions != want {
|
||||
t.Fatalf("permissions = %+v, want %+v", loaded.Permissions, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserPartialGlobalPermissionsMask(t *testing.T) {
|
||||
setupCreateUserDefaultsTest(t)
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "dave",
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
PermissionsFieldMask: users.GlobalPermissionsFieldMask{Admin: true},
|
||||
}
|
||||
if err := CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := GetUserByUsername("dave")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := users.Permissions{Admin: true, Share: true, Api: true, Realtime: true}
|
||||
if loaded.Permissions != want {
|
||||
t.Fatalf("permissions = %+v, want %+v", loaded.Permissions, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserExplicitFalseOverridesDefault(t *testing.T) {
|
||||
setupCreateUserDefaultsTest(t)
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "erin",
|
||||
Permissions: users.Permissions{Share: false, Api: false},
|
||||
},
|
||||
PermissionsFieldMask: users.GlobalPermissionsFieldMask{Share: true, Api: true},
|
||||
}
|
||||
if err := CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := GetUserByUsername("erin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := users.Permissions{Share: false, Api: false, Realtime: true}
|
||||
if loaded.Permissions != want {
|
||||
t.Fatalf("permissions = %+v, want %+v", loaded.Permissions, want)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,14 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Main lowers the bcrypt cost before running the package's tests. Under the
|
||||
@@ -16,7 +19,27 @@ import (
|
||||
// QuickSetup, so every fresh database pays it). Call it from TestMain:
|
||||
//
|
||||
// func TestMain(m *testing.M) { os.Exit(testutil.Main(m)) }
|
||||
//
|
||||
// It also quiets INFO/DEBUG/WARN logger output so failures are readable.
|
||||
// Set FILEBROWSER_TEST_LOG=1 to keep normal logging.
|
||||
func Main(m *testing.M) int {
|
||||
utils.BcryptCost = bcrypt.MinCost
|
||||
QuietLogging()
|
||||
return m.Run()
|
||||
}
|
||||
|
||||
// QuietLogging sends only error/fatal lines to the null device. Safe to call
|
||||
// multiple times; no-ops when FILEBROWSER_TEST_LOG is set.
|
||||
func QuietLogging() {
|
||||
if os.Getenv("FILEBROWSER_TEST_LOG") != "" {
|
||||
return
|
||||
}
|
||||
logger.DisableCompatibilityMode()
|
||||
if err := logger.EnableCompatibilityMode(logger.JsonConfig{
|
||||
Levels: "error|fatal",
|
||||
Output: "stdout",
|
||||
NoColors: true,
|
||||
}); err != nil {
|
||||
log.Println("[ERROR] Failed to set up quiet test logger:", err)
|
||||
}
|
||||
}
|
||||
+220
-36
@@ -229,6 +229,12 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
if destSource == "" {
|
||||
destSource = req.FromSource
|
||||
}
|
||||
if status, roErr := denyIfSourceReadOnly(destSource); roErr != nil {
|
||||
return status, roErr
|
||||
}
|
||||
if status, roErr := denyIfSourceReadOnly(req.FromSource); req.DeleteAfter && roErr != nil {
|
||||
return status, roErr
|
||||
}
|
||||
destPerms, err := effectiveFilePerms(d, destSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
@@ -273,7 +279,7 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
if fullDestParent != fullDestFile && !state.AccessPermitted(idxTo.Path, utils.IndexPathFromNormalized(fullDestParent, true), d.User.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination parent of %s", req.Destination)
|
||||
}
|
||||
parentReal, _, err := idxTo.GetRealPath(fullDestParent)
|
||||
parentReal, err := idxTo.GetRealPathForMutation(userScopeTo, fullDestParent)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %v", err)
|
||||
}
|
||||
@@ -351,26 +357,26 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
|
||||
if req.DeleteAfter {
|
||||
type itemToDelete struct {
|
||||
realPath string
|
||||
isDir bool
|
||||
indexPath string
|
||||
isDir bool
|
||||
}
|
||||
var toDelete []itemToDelete
|
||||
for _, full := range itemPaths {
|
||||
realPath, isDir, err := idx.GetRealPath(full)
|
||||
_, isDir, err := idx.GetRealPath(full)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
toDelete = append(toDelete, itemToDelete{realPath: realPath, isDir: isDir})
|
||||
toDelete = append(toDelete, itemToDelete{indexPath: full, isDir: isDir})
|
||||
}
|
||||
for i := 0; i < len(toDelete); i++ {
|
||||
for j := i + 1; j < len(toDelete); j++ {
|
||||
if len(toDelete[j].realPath) > len(toDelete[i].realPath) {
|
||||
if len(toDelete[j].indexPath) > len(toDelete[i].indexPath) {
|
||||
toDelete[i], toDelete[j] = toDelete[j], toDelete[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range toDelete {
|
||||
if err := files.DeleteFiles(req.FromSource, item.realPath, item.isDir); err != nil {
|
||||
if err := files.DeleteFiles(req.FromSource, userScope, item.indexPath, item.isDir); err != nil {
|
||||
logger.Errorf("Failed to delete source after archive: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -419,6 +425,14 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
if req.ToSource == "" {
|
||||
req.ToSource = req.FromSource
|
||||
}
|
||||
if !req.Preview {
|
||||
if status, roErr := denyIfSourceReadOnly(req.ToSource); roErr != nil {
|
||||
return status, roErr
|
||||
}
|
||||
}
|
||||
if status, roErr := denyIfSourceReadOnly(req.FromSource); req.DeleteAfter && !req.Preview && roErr != nil {
|
||||
return status, roErr
|
||||
}
|
||||
destPerms, err := effectiveFilePerms(d, req.ToSource)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
@@ -461,7 +475,7 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
if !state.AccessPermitted(idxFrom.Path, utils.IndexPathFromNormalized(fullArchivePath, true), d.User.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to archive %s", req.Path)
|
||||
}
|
||||
archiveReal, _, err := idxFrom.GetRealPath(fullArchivePath)
|
||||
archiveReal, _, err := idxFrom.GetRealPathScoped(userScopeFrom, fullArchivePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("archive path not found: %v", err)
|
||||
}
|
||||
@@ -478,7 +492,7 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
if !state.AccessPermitted(idxTo.Path, utils.IndexPathFromNormalized(fullDestPath, true), d.User.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
destReal, _, err := idxTo.GetRealPath(fullDestPath)
|
||||
destReal, _, err := idxTo.GetRealPathScoped(userScopeTo, fullDestPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
|
||||
}
|
||||
@@ -521,11 +535,18 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
defer zr.Close()
|
||||
return RenderJSON(w, r, archiveencoding.Inspect(zr.File), http.StatusOK)
|
||||
}
|
||||
policy := &extractAccessPolicy{
|
||||
idx: idxTo,
|
||||
destRealRoot: destReal,
|
||||
destIndexPrefix: fullDestPath,
|
||||
username: d.User.Username,
|
||||
perms: destPerms,
|
||||
}
|
||||
var extractErr error
|
||||
if strings.HasSuffix(lower, ".zip") {
|
||||
extractErr = extractZipWithEncoding(archiveReal, destReal, req.FilenameEncoding)
|
||||
extractErr = extractZipWithEncoding(archiveReal, destReal, req.FilenameEncoding, policy)
|
||||
} else if strings.HasSuffix(lower, ".tar.gz") || (strings.HasSuffix(lower, ".tgz")) {
|
||||
extractErr = extractTarGz(archiveReal, destReal)
|
||||
extractErr = extractTarGz(archiveReal, destReal, policy)
|
||||
} else {
|
||||
return http.StatusBadRequest, fmt.Errorf("unsupported archive format (use .zip or .tar.gz)")
|
||||
}
|
||||
@@ -533,7 +554,11 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
if errors.Is(extractErr, archiveencoding.ErrEncoding) {
|
||||
return http.StatusBadRequest, extractErr
|
||||
}
|
||||
return http.StatusInternalServerError, extractErr
|
||||
if errors.Is(extractErr, errExtractAccessDenied) {
|
||||
return http.StatusForbidden, extractErr
|
||||
}
|
||||
logger.Errorf("archive extraction failed: %v", extractErr)
|
||||
return http.StatusInternalServerError, clientExtractError()
|
||||
}
|
||||
|
||||
if req.DeleteAfter {
|
||||
@@ -560,7 +585,23 @@ func addFile(source string, path string, d *Context, tarWriter *tar.Writer, zipW
|
||||
return nil // Silently skip this file/folder
|
||||
}
|
||||
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
// Symlink resolution is contained to the share path for shares and the user
|
||||
// scope otherwise; entries that resolve outside the bound are skipped.
|
||||
boundIndexPath := ""
|
||||
if d.Share.Hash != "" {
|
||||
boundIndexPath = d.Share.Path
|
||||
} else {
|
||||
var err error
|
||||
boundIndexPath, err = d.User.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
realPath, _, err := idx.GetRealPathScoped(boundIndexPath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -590,17 +631,26 @@ func addFile(source string, path string, d *Context, tarWriter *tar.Writer, zipW
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check access control for each file/folder during walk
|
||||
if d.Share.Hash == "" {
|
||||
indexRelPath := utils.JoinPathAsUnix(path, relPath)
|
||||
indexRelPath = filepath.ToSlash(indexRelPath)
|
||||
indexPath := utils.IndexPathFromNormalized(indexRelPath, true)
|
||||
if !state.AccessPermitted(idx.Path, indexPath, permUser) {
|
||||
if fileInfo.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
// A symlinked entry must resolve inside the bound before its
|
||||
// contents are opened; escaping entries are silently skipped.
|
||||
indexRelPath := utils.JoinPathAsUnix(path, relPath)
|
||||
indexRelPath = filepath.ToSlash(indexRelPath)
|
||||
if _, _, serr := idx.GetRealPathScoped(boundIndexPath, indexRelPath); serr != nil {
|
||||
if fileInfo.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check access control for each file/folder during walk. Share
|
||||
// downloads are checked as the share owner via permUser, so the
|
||||
// owner's deny rules apply to share visitors as well.
|
||||
indexPath := utils.IndexPathFromNormalized(indexRelPath, true)
|
||||
if !state.AccessPermitted(idx.Path, indexPath, permUser) {
|
||||
if fileInfo.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prepend base folder name unless flatten is true
|
||||
@@ -1022,9 +1072,63 @@ func safeExtractPath(destDir, name string) (string, error) {
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid entry path: %q", name)
|
||||
}
|
||||
if _, err = resolvedWritePathUnderDest(destDir, entryAbs); err != nil {
|
||||
logger.Debugf("unarchive: entry %q path resolution: %v", name, err)
|
||||
return "", fmt.Errorf("invalid entry path: %q", name)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
// resolvedWritePathUnderDest resolves symlink components on entryAbs and ensures the
|
||||
// effective write target stays under the resolved destination root.
|
||||
func resolvedWritePathUnderDest(destDir, entryAbs string) (string, error) {
|
||||
destResolved, err := filepath.EvalSymlinks(destDir)
|
||||
if err != nil {
|
||||
logger.Debugf("unarchive: eval destination symlinks: %v", err)
|
||||
return "", errEntryPathInvalid
|
||||
}
|
||||
check := entryAbs
|
||||
if resolved, rerr := filepath.EvalSymlinks(check); rerr == nil {
|
||||
check = resolved
|
||||
} else if errors.Is(rerr, os.ErrNotExist) {
|
||||
if li, lerr := os.Lstat(entryAbs); lerr == nil && li.Mode()&os.ModeSymlink != 0 {
|
||||
return "", errEntryPathInvalid
|
||||
}
|
||||
parent := filepath.Dir(entryAbs)
|
||||
leaf := filepath.Base(entryAbs)
|
||||
for {
|
||||
resolvedParent, perr := filepath.EvalSymlinks(parent)
|
||||
if perr == nil {
|
||||
check = filepath.Join(resolvedParent, leaf)
|
||||
break
|
||||
}
|
||||
if !errors.Is(perr, os.ErrNotExist) {
|
||||
logger.Debugf("unarchive: eval entry symlinks: %v", perr)
|
||||
return "", errEntryPathInvalid
|
||||
}
|
||||
next := filepath.Dir(parent)
|
||||
if next == parent {
|
||||
logger.Debugf("unarchive: no existing ancestor for entry under %s", destDir)
|
||||
return "", errEntryPathInvalid
|
||||
}
|
||||
leaf = filepath.Join(filepath.Base(parent), leaf)
|
||||
parent = next
|
||||
}
|
||||
} else {
|
||||
logger.Debugf("unarchive: eval entry symlinks: %v", rerr)
|
||||
return "", errEntryPathInvalid
|
||||
}
|
||||
relCheck, err := filepath.Rel(destResolved, check)
|
||||
if err != nil {
|
||||
logger.Debugf("unarchive: relative path under destination: %v", err)
|
||||
return "", errEntryPathInvalid
|
||||
}
|
||||
if relCheck == ".." || strings.HasPrefix(relCheck, ".."+string(filepath.Separator)) {
|
||||
return "", errEntryPathEscape
|
||||
}
|
||||
return check, nil
|
||||
}
|
||||
|
||||
// applyArchivedTimesAndPerm sets permission bits and modification time from an archive entry using
|
||||
func applyArchivedTimesAndPerm(path string, mode fs.FileMode, modTime time.Time) error {
|
||||
switch {
|
||||
@@ -1088,7 +1192,8 @@ func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) er
|
||||
perm = fileutils.EffectiveDirPerm()
|
||||
}
|
||||
if err := os.MkdirAll(destPath, perm); err != nil {
|
||||
return fmt.Errorf("mkdir %q: %w", destPath, err)
|
||||
logger.Debugf("unarchive: mkdir: %v", err)
|
||||
return fmt.Errorf("failed to create directory from archive entry")
|
||||
}
|
||||
return applyArchivedTimesAndPerm(destPath, mode, modTime)
|
||||
}
|
||||
@@ -1098,7 +1203,8 @@ func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.
|
||||
defer func() { _ = src.Close() }()
|
||||
parent := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(parent, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return fmt.Errorf("mkdir %q: %w", parent, err)
|
||||
logger.Debugf("unarchive: mkdir parent: %v", err)
|
||||
return fmt.Errorf("failed to create directory from archive entry")
|
||||
}
|
||||
perm := mode.Perm()
|
||||
if perm == 0 {
|
||||
@@ -1106,15 +1212,18 @@ func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.
|
||||
}
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
logger.Debugf("unarchive: create file: %v", err)
|
||||
return fmt.Errorf("failed to write file from archive entry")
|
||||
}
|
||||
_, copyErr := io.Copy(out, src)
|
||||
cerr := out.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
logger.Debugf("unarchive: write file: %v", copyErr)
|
||||
return fmt.Errorf("failed to write file from archive entry")
|
||||
}
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
logger.Debugf("unarchive: close file: %v", cerr)
|
||||
return fmt.Errorf("failed to write file from archive entry")
|
||||
}
|
||||
return applyArchivedTimesAndPerm(destPath, mode, modTime)
|
||||
}
|
||||
@@ -1124,25 +1233,28 @@ func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time
|
||||
target = strings.TrimSpace(target)
|
||||
symParent := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(symParent, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return fmt.Errorf("mkdir %q: %w", symParent, err)
|
||||
logger.Debugf("unarchive: mkdir symlink parent: %v", err)
|
||||
return fmt.Errorf("failed to create symlink from archive entry")
|
||||
}
|
||||
if err := symlinkTargetStaysUnderDest(destRoot, destPath, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
logger.Debugf("unarchive: remove symlink: %v", err)
|
||||
return fmt.Errorf("failed to create symlink from archive entry")
|
||||
}
|
||||
if err := os.Symlink(target, destPath); err != nil {
|
||||
return err
|
||||
logger.Debugf("unarchive: symlink: %v", err)
|
||||
return fmt.Errorf("failed to create symlink from archive entry")
|
||||
}
|
||||
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
|
||||
}
|
||||
|
||||
func extractZip(archivePath, destDir string) error {
|
||||
return extractZipWithEncoding(archivePath, destDir, "")
|
||||
return extractZipWithEncoding(archivePath, destDir, "", nil)
|
||||
}
|
||||
|
||||
func extractZipWithEncoding(archivePath, destDir, charset string) error {
|
||||
func extractZipWithEncoding(archivePath, destDir, charset string, policy *extractAccessPolicy) error {
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1199,6 +1311,23 @@ func extractZipWithEncoding(archivePath, destDir, charset string) error {
|
||||
return ordered[i].f.Name < ordered[j].f.Name
|
||||
})
|
||||
|
||||
for _, o := range ordered {
|
||||
destPath, pathErr := safeExtractPath(destDir, o.name)
|
||||
if pathErr != nil {
|
||||
return pathErr
|
||||
}
|
||||
fi := o.f.FileInfo()
|
||||
mode := fi.Mode()
|
||||
isDirEntry := mode.Type() == fs.ModeDir || strings.HasSuffix(o.name, "/") || strings.HasSuffix(o.name, "\\")
|
||||
isSymlink := mode.Type() == fs.ModeSymlink
|
||||
if !isDirEntry && !isSymlink && !mode.IsRegular() {
|
||||
continue
|
||||
}
|
||||
if err = preflightExtractEntry(policy, o.name, destPath, isDirEntry, isSymlink); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, o := range ordered {
|
||||
f := o.f
|
||||
var destPath string
|
||||
@@ -1250,7 +1379,10 @@ func extractZipWithEncoding(archivePath, destDir, charset string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTarGz(archivePath, destDir string) error {
|
||||
func extractTarGz(archivePath, destDir string, policy *extractAccessPolicy) error {
|
||||
if err := preflightTarGz(archivePath, destDir, policy); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1272,8 +1404,7 @@ func extractTarGz(archivePath, destDir string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var destPath string
|
||||
destPath, err = safeExtractPath(destDir, h.Name)
|
||||
destPath, err := safeExtractPath(destDir, h.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1304,6 +1435,59 @@ func extractTarGz(archivePath, destDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightTarGz(archivePath, destDir string, policy *extractAccessPolicy) error {
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destPath, err := safeExtractPath(destDir, h.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err = preflightExtractEntry(policy, h.Name, destPath, true, false); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err = preflightExtractEntry(policy, h.Name, destPath, false, false); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeSymlink:
|
||||
if err = preflightExtractEntry(policy, h.Name, destPath, false, true); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if h.Size > 0 {
|
||||
if _, err = io.CopyN(io.Discard, tr, h.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeArchiveSize returns the combined size of the given paths, respecting access rules.
|
||||
// Paths denied by access are skipped (not counted).
|
||||
func computeArchiveSize(source string, fileList []string, d *Context) (int64, error) {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// extractAccessPolicy enforces per-path access rules and source file permissions during unarchive.
|
||||
type extractAccessPolicy struct {
|
||||
idx *indexing.Index
|
||||
destRealRoot string
|
||||
destIndexPrefix string
|
||||
username string
|
||||
perms users.SourceFilePermissions
|
||||
}
|
||||
|
||||
// allowWriteSymlinkEntry authorizes creating or replacing a symlink at destPath without following the final path component.
|
||||
func (p *extractAccessPolicy) allowWriteSymlinkEntry(archiveEntryName, destPath string) error {
|
||||
if p == nil || p.idx == nil {
|
||||
return nil
|
||||
}
|
||||
parent := filepath.Dir(destPath)
|
||||
if err := p.allowWriteIncludingParents(archiveEntryName, parent); err != nil {
|
||||
return err
|
||||
}
|
||||
destResolved, err := filepath.EvalSymlinks(p.destRealRoot)
|
||||
if err != nil {
|
||||
logger.Debugf("unarchive: eval destination symlinks: %v", err)
|
||||
return errEntryPathInvalid
|
||||
}
|
||||
// Resolve the parent chain only: the final component is the link being
|
||||
// created or replaced and must be checked at the link path, not its target.
|
||||
linkPath, err := resolvedLinkPath(destPath)
|
||||
if err != nil {
|
||||
logger.Debugf("unarchive: resolve symlink entry parent: %v", err)
|
||||
return errEntryPathInvalid
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Clean(destResolved), linkPath)
|
||||
if err != nil {
|
||||
logger.Debugf("unarchive: symlink entry relative path: %v", err)
|
||||
return errEntryPathInvalid
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return errEntryPathEscape
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
indexPath := utils.JoinPathAsUnix(p.destIndexPrefix, rel)
|
||||
if !state.AccessPermitted(p.idx.Path, utils.IndexPathFromNormalized(indexPath, true), p.username) {
|
||||
logger.Debugf("unarchive: symlink entry %q denied at index path %s for user %s", archiveEntryName, indexPath, p.username)
|
||||
return newExtractAccessDenied(archiveEntryName)
|
||||
}
|
||||
if _, statErr := os.Lstat(destPath); os.IsNotExist(statErr) {
|
||||
if !p.perms.Create {
|
||||
return fmt.Errorf("%w: create permission required", newExtractAccessDenied(archiveEntryName))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !p.perms.Modify {
|
||||
return fmt.Errorf("%w: modify permission required", newExtractAccessDenied(archiveEntryName))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvedLinkPath resolves symlink components in the parents of p without
|
||||
// following the final path component, so checks apply to the link path itself.
|
||||
func resolvedLinkPath(p string) (string, error) {
|
||||
parent := filepath.Dir(p)
|
||||
leaf := filepath.Base(p)
|
||||
for {
|
||||
resolved, err := filepath.EvalSymlinks(parent)
|
||||
if err == nil {
|
||||
return filepath.Join(resolved, leaf), nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
next := filepath.Dir(parent)
|
||||
if next == parent {
|
||||
return "", err
|
||||
}
|
||||
leaf = filepath.Join(filepath.Base(parent), leaf)
|
||||
parent = next
|
||||
}
|
||||
}
|
||||
|
||||
// allowWrite checks index access and source Create/Modify permissions for one destination path.
|
||||
func (p *extractAccessPolicy) allowWrite(archiveEntryName, destPath string) error {
|
||||
if p == nil || p.idx == nil {
|
||||
return nil
|
||||
}
|
||||
effectivePath, err := resolvedWritePathUnderDest(p.destRealRoot, destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destResolved, err := filepath.EvalSymlinks(p.destRealRoot)
|
||||
if err != nil {
|
||||
logger.Debugf("unarchive: eval destination symlinks: %v", err)
|
||||
return errEntryPathInvalid
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Clean(destResolved), filepath.Clean(effectivePath))
|
||||
if err != nil {
|
||||
logger.Debugf("unarchive: entry relative path: %v", err)
|
||||
return errEntryPathInvalid
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
indexPath := utils.JoinPathAsUnix(p.destIndexPrefix, rel)
|
||||
if !state.AccessPermitted(p.idx.Path, utils.IndexPathFromNormalized(indexPath, true), p.username) {
|
||||
logger.Debugf("unarchive: entry %q denied at index path %s for user %s", archiveEntryName, indexPath, p.username)
|
||||
return newExtractAccessDenied(archiveEntryName)
|
||||
}
|
||||
if _, statErr := os.Stat(destPath); os.IsNotExist(statErr) {
|
||||
if !p.perms.Create {
|
||||
return fmt.Errorf("%w: create permission required", newExtractAccessDenied(archiveEntryName))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !p.perms.Modify {
|
||||
return fmt.Errorf("%w: modify permission required", newExtractAccessDenied(archiveEntryName))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// allowWriteIncludingParents authorizes destPath and every missing ancestor directory that extraction would create.
|
||||
func (p *extractAccessPolicy) allowWriteIncludingParents(archiveEntryName, destPath string) error {
|
||||
if p == nil || p.idx == nil {
|
||||
return nil
|
||||
}
|
||||
destRoot := filepath.Clean(p.destRealRoot)
|
||||
var missingParents []string
|
||||
for dir := filepath.Dir(destPath); ; dir = filepath.Dir(dir) {
|
||||
cleanDir := filepath.Clean(dir)
|
||||
if cleanDir == destRoot {
|
||||
break
|
||||
}
|
||||
rel, relErr := filepath.Rel(destRoot, cleanDir)
|
||||
if relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
break
|
||||
}
|
||||
if _, statErr := os.Stat(cleanDir); os.IsNotExist(statErr) {
|
||||
missingParents = append(missingParents, cleanDir)
|
||||
}
|
||||
}
|
||||
for i := len(missingParents) - 1; i >= 0; i-- {
|
||||
if err := p.allowWrite(archiveEntryName, missingParents[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return p.allowWrite(archiveEntryName, destPath)
|
||||
}
|
||||
|
||||
func preflightExtractEntry(policy *extractAccessPolicy, archiveEntryName, destPath string, isDir, isSymlink bool) error {
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
if isSymlink {
|
||||
return policy.allowWriteSymlinkEntry(archiveEntryName, destPath)
|
||||
}
|
||||
return policy.allowWriteIncludingParents(archiveEntryName, destPath)
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupExtractAccessPolicyTest(t *testing.T) (destDir, sourceRoot, sourceName, username string) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "access.sqlite")
|
||||
if _, err := state.Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(state.Default())
|
||||
t.Cleanup(func() { state.Close() })
|
||||
|
||||
sourceRoot = filepath.Join(tempDir, "data")
|
||||
destDir = filepath.Join(sourceRoot, "dest")
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName = "data"
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
sourceRoot: {Path: sourceRoot, Name: sourceName},
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
sourceName: settings.Config.Server.SourceMap[sourceRoot],
|
||||
}
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
username = "extract-user"
|
||||
perms := fullPerms()
|
||||
user := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{Username: username},
|
||||
BackendScopes: []users.BackendScope{{
|
||||
Path: sourceRoot,
|
||||
Scope: "/",
|
||||
Permissions: perms,
|
||||
}},
|
||||
BackendSourcePermissions: map[string]users.SourceFilePermissions{
|
||||
sourceRoot: perms,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(user, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.AllowUser(sourceRoot, utils.IndexPathFromNormalized("/", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
indexing.SetTestIndex(sourceName, sourceRoot)
|
||||
t.Cleanup(func() { indexing.ClearTestIndices() })
|
||||
|
||||
return destDir, sourceRoot, sourceName, username
|
||||
}
|
||||
|
||||
func testExtractPolicy(t *testing.T, destDir, sourceName, username string, perms users.SourceFilePermissions) *extractAccessPolicy {
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatal("missing index")
|
||||
}
|
||||
return &extractAccessPolicy{
|
||||
idx: idx,
|
||||
destRealRoot: destDir,
|
||||
destIndexPrefix: "/dest",
|
||||
username: username,
|
||||
perms: perms,
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestZip(t *testing.T, path string, write func(zw *zip.Writer) error) {
|
||||
t.Helper()
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
if err := write(zw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestTarGz(t *testing.T, path string, write func(tw *tar.Writer) error) {
|
||||
t.Helper()
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gz := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gz)
|
||||
if err := write(tw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fullPerms() users.SourceFilePermissions {
|
||||
return users.SourceFilePermissions{
|
||||
View: true, Download: true, Modify: true, Create: true, Delete: true,
|
||||
}
|
||||
}
|
||||
|
||||
func requireExtractAccessDenied(t *testing.T, err error, destReal string, wantEntryInMsg string) {
|
||||
t.Helper()
|
||||
if !errors.Is(err, errExtractAccessDenied) {
|
||||
t.Fatalf("expected access-rule denial, got %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(err.Error(), "access denied: ") {
|
||||
t.Fatalf("unexpected denial message: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), destReal) {
|
||||
t.Fatalf("denial error leaked real path: %v", err)
|
||||
}
|
||||
if wantEntryInMsg != "" && !strings.Contains(err.Error(), wantEntryInMsg) {
|
||||
t.Fatalf("expected archive entry %q in %v", wantEntryInMsg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractZipWithEncoding_accessPolicy(t *testing.T) {
|
||||
t.Run("allowed creation", func(t *testing.T) {
|
||||
destDir, _, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
zipPath := filepath.Join(t.TempDir(), "ok.zip")
|
||||
writeTestZip(t, zipPath, func(zw *zip.Writer) error {
|
||||
w, err := zw.Create("new.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte("ok"))
|
||||
return err
|
||||
})
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, fullPerms())
|
||||
if err := extractZipWithEncoding(zipPath, destDir, "", policy); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails when parent path denied by access rules without writing", func(t *testing.T) {
|
||||
destDir, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/blocked", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "parent.zip")
|
||||
writeTestZip(t, zipPath, func(zw *zip.Writer) error {
|
||||
w, err := zw.Create("allowed.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = w.Write([]byte("ok")); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err = zw.Create("blocked/child.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte("nope"))
|
||||
return err
|
||||
})
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, fullPerms())
|
||||
err := extractZipWithEncoding(zipPath, destDir, "", policy)
|
||||
requireExtractAccessDenied(t, err, destDir, "blocked/child.txt")
|
||||
if _, statErr := os.Stat(filepath.Join(destDir, "allowed.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatal("preflight should prevent partial extraction")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(destDir, "blocked", "child.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatal("expected denied path not written")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("denied modification", func(t *testing.T) {
|
||||
destDir, _, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
existing := filepath.Join(destDir, "existing.txt")
|
||||
if err := os.WriteFile(existing, []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "mod.zip")
|
||||
writeTestZip(t, zipPath, func(zw *zip.Writer) error {
|
||||
w, err := zw.Create("existing.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte("new"))
|
||||
return err
|
||||
})
|
||||
noModify := fullPerms()
|
||||
noModify.Modify = false
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, noModify)
|
||||
if err := extractZipWithEncoding(zipPath, destDir, "", policy); err == nil {
|
||||
t.Fatal("expected modify permission error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails when entry resolves through denied symlink target", func(t *testing.T) {
|
||||
destDir, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
blocked := filepath.Join(destDir, "blocked")
|
||||
if err := os.MkdirAll(blocked, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alias := filepath.Join(destDir, "alias")
|
||||
if err := os.Symlink("blocked", alias); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/blocked", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "symlink.zip")
|
||||
writeTestZip(t, zipPath, func(zw *zip.Writer) error {
|
||||
w, err := zw.Create("alias/secret.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte("nope"))
|
||||
return err
|
||||
})
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, fullPerms())
|
||||
requireExtractAccessDenied(t, extractZipWithEncoding(zipPath, destDir, "", policy), destDir, "alias/secret.txt")
|
||||
if _, err := os.Stat(filepath.Join(destDir, "alias", "secret.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("expected entry under denied target not written")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("symlink entry requires policy", func(t *testing.T) {
|
||||
destDir, _, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
zipPath := filepath.Join(t.TempDir(), "link.zip")
|
||||
writeTestZip(t, zipPath, func(zw *zip.Writer) error {
|
||||
hdr := &zip.FileHeader{Name: "thelink", Method: zip.Store}
|
||||
hdr.SetMode(0o777 | os.ModeSymlink)
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte("target.txt"))
|
||||
return err
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(destDir, "target.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noCreate := fullPerms()
|
||||
noCreate.Create = false
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, noCreate)
|
||||
if err := extractZipWithEncoding(zipPath, destDir, "", policy); err == nil {
|
||||
t.Fatal("expected create permission error for symlink entry")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips denied symlink entry at link path", func(t *testing.T) {
|
||||
destDir, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
if err := os.WriteFile(filepath.Join(destDir, "allowed.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkName := "secret-link"
|
||||
linkPath := filepath.Join(destDir, linkName)
|
||||
if err := os.Symlink("allowed.txt", linkPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/"+linkName, true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "replace-link.zip")
|
||||
writeTestZip(t, zipPath, func(zw *zip.Writer) error {
|
||||
hdr := &zip.FileHeader{Name: linkName, Method: zip.Store}
|
||||
hdr.SetMode(0o777 | os.ModeSymlink)
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte("other.txt"))
|
||||
return err
|
||||
})
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, fullPerms())
|
||||
requireExtractAccessDenied(t, extractZipWithEncoding(zipPath, destDir, "", policy), destDir, linkName)
|
||||
target, err := os.Readlink(linkPath)
|
||||
if err != nil || target != "allowed.txt" {
|
||||
t.Fatalf("symlink should be unchanged, target=%q err=%v", target, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails mixed archive when any entry denied without partial write", func(t *testing.T) {
|
||||
destDir, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/hidden.txt", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "mixed.zip")
|
||||
writeTestZip(t, zipPath, func(zw *zip.Writer) error {
|
||||
for _, name := range []string{"public.txt", "hidden.txt"} {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = w.Write([]byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, fullPerms())
|
||||
requireExtractAccessDenied(t, extractZipWithEncoding(zipPath, destDir, "", policy), destDir, "hidden.txt")
|
||||
if _, err := os.Stat(filepath.Join(destDir, "public.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("preflight should prevent partial extraction")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destDir, "hidden.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("expected hidden.txt not written")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dangling symlink requires modify", func(t *testing.T) {
|
||||
destDir, _, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
linkName := "dangling"
|
||||
if err := os.Symlink("missing-target", filepath.Join(destDir, linkName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "replace-dangling.zip")
|
||||
writeTestZip(t, zipPath, func(zw *zip.Writer) error {
|
||||
hdr := &zip.FileHeader{Name: linkName, Method: zip.Store}
|
||||
hdr.SetMode(0o777 | os.ModeSymlink)
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte("new-target"))
|
||||
return err
|
||||
})
|
||||
perms := fullPerms()
|
||||
perms.Modify = false
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, perms)
|
||||
if err := extractZipWithEncoding(zipPath, destDir, "", policy); err == nil {
|
||||
t.Fatal("expected modify permission error when replacing dangling symlink")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractTarGz_accessPolicy(t *testing.T) {
|
||||
t.Run("allowed creation", func(t *testing.T) {
|
||||
destDir, _, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
archivePath := filepath.Join(t.TempDir(), "ok.tar.gz")
|
||||
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "new.txt", Mode: 0o644, Size: 2, Typeflag: tar.TypeReg}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := io.WriteString(tw, "ok")
|
||||
return err
|
||||
})
|
||||
if err := extractTarGz(archivePath, destDir, testExtractPolicy(t, destDir, sourceName, username, fullPerms())); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails when parent path denied by access rules", func(t *testing.T) {
|
||||
destDir, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/blocked", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archivePath := filepath.Join(t.TempDir(), "parent.tar.gz")
|
||||
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "blocked/child.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := io.WriteString(tw, "nope")
|
||||
return err
|
||||
})
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, fullPerms())
|
||||
requireExtractAccessDenied(t, extractTarGz(archivePath, destDir, policy), destDir, "blocked/child.txt")
|
||||
})
|
||||
|
||||
t.Run("symlink entry requires policy", func(t *testing.T) {
|
||||
destDir, _, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
archivePath := filepath.Join(t.TempDir(), "link.tar.gz")
|
||||
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
|
||||
return tw.WriteHeader(&tar.Header{
|
||||
Name: "thelink",
|
||||
Mode: 0o777,
|
||||
Typeflag: tar.TypeSymlink,
|
||||
Linkname: "target.txt",
|
||||
})
|
||||
})
|
||||
if err := os.WriteFile(filepath.Join(destDir, "target.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noCreate := fullPerms()
|
||||
noCreate.Create = false
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, noCreate)
|
||||
if err := extractTarGz(archivePath, destDir, policy); err == nil {
|
||||
t.Fatal("expected create permission error for symlink entry")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips denied symlink entry at link path", func(t *testing.T) {
|
||||
destDir, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
if err := os.WriteFile(filepath.Join(destDir, "allowed.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkName := "secret-link"
|
||||
if err := os.Symlink("allowed.txt", filepath.Join(destDir, linkName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/"+linkName, true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archivePath := filepath.Join(t.TempDir(), "replace-link.tar.gz")
|
||||
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
|
||||
return tw.WriteHeader(&tar.Header{
|
||||
Name: linkName,
|
||||
Mode: 0o777,
|
||||
Typeflag: tar.TypeSymlink,
|
||||
Linkname: "other.txt",
|
||||
})
|
||||
})
|
||||
policy := testExtractPolicy(t, destDir, sourceName, username, fullPerms())
|
||||
requireExtractAccessDenied(t, extractTarGz(archivePath, destDir, policy), destDir, linkName)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnarchiveHandler_accessDeniedAndSanitizesErrors(t *testing.T) {
|
||||
destDir, sourceRoot, sourceName, username := setupExtractAccessPolicyTest(t)
|
||||
user, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.DenyUser(sourceRoot, utils.IndexPathFromNormalized("/dest/hidden.txt", true), username); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
archivePath := filepath.Join(sourceRoot, "bundle.zip")
|
||||
writeTestZip(t, archivePath, func(zw *zip.Writer) error {
|
||||
for _, name := range []string{"visible.txt", "hidden.txt"} {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = w.Write([]byte("x")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
body := `{"fromSource":"data","toSource":"data","path":"/bundle.zip","destination":"/dest"}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/unarchive", strings.NewReader(body))
|
||||
status, handlerErr := unarchiveHandler(rec, req, &Context{User: &user})
|
||||
if status != http.StatusForbidden || handlerErr == nil {
|
||||
t.Fatalf("status=%d err=%v body=%s", status, handlerErr, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(handlerErr.Error(), "access denied: hidden.txt") {
|
||||
t.Fatalf("unexpected error: %v", handlerErr)
|
||||
}
|
||||
if strings.Contains(handlerErr.Error(), destDir) || strings.Contains(handlerErr.Error(), sourceRoot) {
|
||||
t.Fatalf("client error leaked real path: %v", handlerErr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destDir, "visible.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("preflight should prevent partial extraction")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destDir, "hidden.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("expected hidden.txt not written")
|
||||
}
|
||||
|
||||
// Permission failure must not expose the real destination path in the client error.
|
||||
existing := filepath.Join(destDir, "locked.txt")
|
||||
if err := os.WriteFile(existing, []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overwriteZip := filepath.Join(sourceRoot, "overwrite.zip")
|
||||
writeTestZip(t, overwriteZip, func(zw *zip.Writer) error {
|
||||
w, err := zw.Create("locked.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte("new"))
|
||||
return err
|
||||
})
|
||||
noModify := fullPerms()
|
||||
noModify.Modify = false
|
||||
user.BackendSourcePermissions[sourceRoot] = noModify
|
||||
for i := range user.BackendScopes {
|
||||
if user.BackendScopes[i].Path == sourceRoot {
|
||||
user.BackendScopes[i].Permissions = noModify
|
||||
}
|
||||
}
|
||||
body = `{"fromSource":"data","toSource":"data","path":"/overwrite.zip","destination":"/dest"}`
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/resources/unarchive", strings.NewReader(body))
|
||||
status, handlerErr = unarchiveHandler(rec, req, &Context{User: &user})
|
||||
if status != http.StatusForbidden || handlerErr == nil {
|
||||
t.Fatalf("status=%d err=%v", status, handlerErr)
|
||||
}
|
||||
if strings.Contains(handlerErr.Error(), destDir) || strings.Contains(handlerErr.Error(), sourceRoot) {
|
||||
t.Fatalf("client error leaked real path: %v", handlerErr)
|
||||
}
|
||||
if !strings.Contains(handlerErr.Error(), "modify permission required") {
|
||||
t.Fatalf("unexpected client error: %v", handlerErr)
|
||||
}
|
||||
_ = sourceName
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// errExtractAccessDenied marks access-rule denials during extraction (use errors.Is).
|
||||
var errExtractAccessDenied = errors.New("extract access denied")
|
||||
|
||||
type extractAccessDeniedError struct {
|
||||
entry string
|
||||
}
|
||||
|
||||
func (e *extractAccessDeniedError) Error() string {
|
||||
return fmt.Sprintf("access denied: %s", e.entry)
|
||||
}
|
||||
|
||||
func (e *extractAccessDeniedError) Unwrap() error {
|
||||
return errExtractAccessDenied
|
||||
}
|
||||
|
||||
func newExtractAccessDenied(archiveEntryName string) error {
|
||||
display := archiveEntryName
|
||||
if normalized, err := normalizeArchiveEntryName(archiveEntryName); err == nil && normalized != "" {
|
||||
display = normalized
|
||||
}
|
||||
return &extractAccessDeniedError{entry: display}
|
||||
}
|
||||
|
||||
// errEntryPathInvalid and errEntryPathEscape are returned from path resolution without filesystem paths in Error().
|
||||
var (
|
||||
errEntryPathInvalid = errors.New("archive entry path is invalid")
|
||||
errEntryPathEscape = errors.New("archive entry path escapes destination")
|
||||
)
|
||||
|
||||
// clientExtractError returns a generic extraction failure for HTTP responses (details are logged server-side).
|
||||
func clientExtractError() error {
|
||||
return errors.New("archive extraction failed")
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func TestExtractZipFilenameEncoding(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := t.TempDir()
|
||||
err = extractZipWithEncoding(archivePath, out, tc.charset)
|
||||
err = extractZipWithEncoding(archivePath, out, tc.charset, nil)
|
||||
if tc.invalid {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
|
||||
@@ -17,6 +17,11 @@ type stubGroupSQL struct {
|
||||
|
||||
func (s *stubGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
|
||||
func (s *stubGroupSQL) DeleteAccessRule(string, string) error { return nil }
|
||||
func (s *stubGroupSQL) AccessRulesLowercaseMigrated() (bool, error) { return false, nil }
|
||||
func (s *stubGroupSQL) SetAccessRulesLowercaseMigrated() error { return nil }
|
||||
func (s *stubGroupSQL) MigrateAccessRulePathsLowercase(string, []string, access.RuleMap) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubGroupSQL) SaveGroup(string, access.StringSet) error { return s.saveErr }
|
||||
func (s *stubGroupSQL) DeleteGroup(string) error { return nil }
|
||||
func (s *stubGroupSQL) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
|
||||
|
||||
@@ -322,7 +322,7 @@ func RawFilesHandler(w http.ResponseWriter, r *http.Request, d *Context, source
|
||||
}
|
||||
_, isDir, err = idx.GetRealPathScoped(d.Share.Path, firstFilePath)
|
||||
} else {
|
||||
_, isDir, err = idx.GetRealPath(firstFilePath)
|
||||
_, isDir, err = idx.GetRealPathScoped(userscope, firstFilePath)
|
||||
}
|
||||
if err != nil {
|
||||
s := realPathErrStatus(err)
|
||||
|
||||
@@ -202,7 +202,7 @@ func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
|
||||
// Generate cache key from all input parameters that affect results
|
||||
// Checksums are always enabled, so cache key doesn't need to include that flag
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d", index.Path, opts.combinedPath, opts.minSize)
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s:%d", index.Path, opts.combinedPath, opts.username, opts.minSize)
|
||||
|
||||
// Check cache first (before acquiring mutex)
|
||||
if cachedResults, ok := duplicateResultsCache.Get(cacheKey); ok {
|
||||
|
||||
@@ -258,8 +258,11 @@ func GetRemoteIP(r *http.Request) string {
|
||||
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if cfg.Http.TrustProxyHeaders && xff != "" {
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[0])
|
||||
// Use the rightmost hop: the trusted proxy appends the connecting client, so the
|
||||
// leftmost value is attacker-controlled when clients send X-Forwarded-For.
|
||||
if ip := lastForwardedValue(xff); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
xri := r.Header.Get("X-Real-IP")
|
||||
|
||||
@@ -645,6 +645,10 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
return returnOnlyOfficeSuccess(w, r)
|
||||
}
|
||||
|
||||
if status, roErr := denyIfSourceReadOnly(source); roErr != nil {
|
||||
return returnOnlyOfficeError(w, r, status, roErr.Error())
|
||||
}
|
||||
|
||||
// Check modify permission for save operations
|
||||
filePerms, permErr := effectiveFilePerms(d, source)
|
||||
if permErr != nil || !filePerms.Modify {
|
||||
@@ -712,7 +716,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
writeErr := files.WriteFile(source, fullIndexPath, doc.Body)
|
||||
writeErr := files.WriteFile(source, userScope, fullIndexPath, doc.Body)
|
||||
if writeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: failed to write updated document to path=%s: %v",
|
||||
path, writeErr)
|
||||
|
||||
@@ -247,7 +247,7 @@ func TestOnlyOfficeCallbackRetainsKeyWhenSaveFails(t *testing.T) {
|
||||
utils.OnlyOfficeCache.Set(realPath, docKey)
|
||||
|
||||
writeCalls := 0
|
||||
files.WriteFileFunc = func(string, string, io.Reader) error {
|
||||
files.WriteFileFunc = func(string, string, string, io.Reader) error {
|
||||
writeCalls++
|
||||
return errors.New("write failed")
|
||||
}
|
||||
@@ -276,7 +276,7 @@ func TestOnlyOfficeCallbackRetainsKeyWhenSaveFails(t *testing.T) {
|
||||
|
||||
// The document server retries the callback with the same key; the session
|
||||
// must still resolve and the save must be allowed to complete.
|
||||
files.WriteFileFunc = func(string, string, io.Reader) error {
|
||||
files.WriteFileFunc = func(string, string, string, io.Reader) error {
|
||||
writeCalls++
|
||||
return nil
|
||||
}
|
||||
@@ -334,7 +334,7 @@ func TestOnlyOfficeCallbackSerializesConcurrentSaves(t *testing.T) {
|
||||
writeCalls, active, maxActive := 0, 0, 0
|
||||
writeStarted := make(chan struct{}, 2)
|
||||
writeRelease := make(chan struct{})
|
||||
files.WriteFileFunc = func(string, string, io.Reader) error {
|
||||
files.WriteFileFunc = func(string, string, string, io.Reader) error {
|
||||
writesMu.Lock()
|
||||
writeCalls++
|
||||
active++
|
||||
|
||||
@@ -74,10 +74,11 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, er
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to view files in this source")
|
||||
}
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
AlbumArt: true, // Extract album art for audio previews
|
||||
Expand: true,
|
||||
Path: path,
|
||||
Source: source,
|
||||
AlbumArt: true, // Extract album art for audio previews
|
||||
Expand: true,
|
||||
FollowSymlinks: true,
|
||||
}, d.User)
|
||||
if err != nil {
|
||||
logger.Errorf("error getting file info: %v", err)
|
||||
|
||||
@@ -19,6 +19,17 @@ func normalizeHTTPScheme(proto string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func lastForwardedValue(v string) string {
|
||||
parts := strings.Split(v, ",")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
ip := strings.TrimSpace(parts[i])
|
||||
if ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstForwardedValue(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if i := strings.Index(v, ","); i >= 0 {
|
||||
|
||||
@@ -8,6 +8,18 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = false })
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/", nil)
|
||||
req.RemoteAddr = "10.0.0.1:1234"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.50, 198.51.100.7")
|
||||
if got := GetRemoteIP(req); got != "198.51.100.7" {
|
||||
t.Fatalf("GetRemoteIP = %q, want 198.51.100.7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetScheme(t *testing.T) {
|
||||
t.Run("trusted proto", func(t *testing.T) {
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
@@ -47,9 +49,9 @@ func symlinkPutFixture(t *testing.T) (downloadsPath string, idx *indexing.Index)
|
||||
func TestResolvePutRealPath_NewFileViaParent(t *testing.T) {
|
||||
_, idx := symlinkPutFixture(t)
|
||||
|
||||
realPath, err := resolvePutRealPath(idx, "/projects/newfile.txt")
|
||||
realPath, err := idx.GetRealPathForMutation("", "/projects/newfile.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePutRealPath: %v", err)
|
||||
t.Fatalf("GetRealPathForMutation: %v", err)
|
||||
}
|
||||
parentReal, isDir, err := idx.GetRealPath("/projects")
|
||||
if err != nil || !isDir {
|
||||
@@ -64,7 +66,7 @@ func TestResolvePutRealPath_NewFileViaParent(t *testing.T) {
|
||||
func TestResolvePutRealPath_RejectsEscapingParent(t *testing.T) {
|
||||
_, idx := symlinkPutFixture(t)
|
||||
|
||||
_, err := resolvePutRealPath(idx, "/evil/hack.txt")
|
||||
_, err := idx.GetRealPathForMutation("", "/evil/hack.txt")
|
||||
if !errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
|
||||
}
|
||||
@@ -72,7 +74,7 @@ func TestResolvePutRealPath_RejectsEscapingParent(t *testing.T) {
|
||||
|
||||
func TestSanitizePutPathError_NoFilesystemPaths(t *testing.T) {
|
||||
_, idx := symlinkPutFixture(t)
|
||||
_, internalErr := resolvePutRealPath(idx, "/evil/hack.txt")
|
||||
_, internalErr := idx.GetRealPathForMutation("", "/evil/hack.txt")
|
||||
if internalErr == nil {
|
||||
t.Fatal("expected resolution error")
|
||||
}
|
||||
@@ -118,6 +120,67 @@ func TestResourcePutHandler_CreatesNewFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePutHandler_PreservesFilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix permission bits are not supported on Windows")
|
||||
}
|
||||
originalPerm := fileutils.PermFile
|
||||
fileutils.PermFile = 0600
|
||||
t.Cleanup(func() { fileutils.PermFile = originalPerm })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mode os.FileMode
|
||||
}{
|
||||
{"new", 0},
|
||||
{"readable", 0604},
|
||||
{"executable", 0755},
|
||||
{"privileged", 0755 | os.ModeSetuid | os.ModeSetgid},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := setupPutHTTPTest(t)
|
||||
path := filepath.Join(root, "projects", "edit.txt")
|
||||
if tc.mode != 0 {
|
||||
if err := os.WriteFile(path, []byte("before"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, tc.mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.Stat(path)
|
||||
if err != nil || before.Mode() != tc.mode {
|
||||
t.Fatalf("could not set initial mode: info=%v err=%v", before, err)
|
||||
}
|
||||
}
|
||||
admin, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/resources?source=Downloads&path=/projects/edit.txt", strings.NewReader("after"))
|
||||
status, err := resourcePutHandler(httptest.NewRecorder(), req, &requestContext{User: &admin})
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("put failed: status=%d err=%v", status, err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil || string(content) != "after" {
|
||||
t.Fatalf("content=%q err=%v", content, err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := tc.mode.Perm()
|
||||
if want == 0 {
|
||||
want = 0600
|
||||
}
|
||||
mode := info.Mode() & (os.ModePerm | os.ModeSetuid | os.ModeSetgid | os.ModeSticky)
|
||||
if mode != want {
|
||||
t.Fatalf("mode=%v want %v", mode, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePutHandler_RejectsEscapingParentSymlink(t *testing.T) {
|
||||
downloadsPath := setupPutHTTPTest(t)
|
||||
outside := filepath.Join(filepath.Dir(downloadsPath), "outside-target")
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/quota"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
func uploadQuotaContext(d *Context, sourceName, fullIndexPath, realPath string, r *http.Request, sessionID string, totalSize int64, hasTotalSize bool) quota.UploadContext {
|
||||
@@ -35,7 +35,7 @@ func uploadQuotaContext(d *Context, sourceName, fullIndexPath, realPath string,
|
||||
}
|
||||
|
||||
func buildUploadQuotaContext(principal *users.User, sourceName, sourcePath, fullIndexPath, realPath, shareHash string, shareLimit int64, sessionID string, totalSize int64, hasTotalSize bool) quota.UploadContext {
|
||||
return quota.UploadContext{
|
||||
ctx := quota.UploadContext{
|
||||
Principal: principal,
|
||||
SourceName: sourceName,
|
||||
SourcePath: sourcePath,
|
||||
@@ -47,38 +47,17 @@ func buildUploadQuotaContext(principal *users.User, sourceName, sourcePath, full
|
||||
TotalSize: totalSize,
|
||||
HasKnownSize: hasTotalSize,
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePutRealPath maps an index path to a real filesystem path for PUT.
|
||||
// Existing files resolve directly; missing final components resolve via the parent directory.
|
||||
func resolvePutRealPath(idx *indexing.Index, fullIndexPath string) (string, error) {
|
||||
realPath, isDir, err := idx.GetRealPath(fullIndexPath)
|
||||
if err == nil {
|
||||
if isDir {
|
||||
return "", fmt.Errorf("path is a directory")
|
||||
if realPath != "" {
|
||||
stat, err := os.Stat(realPath)
|
||||
if err == nil && !stat.IsDir() {
|
||||
ctx.PriorFileSize = stat.Size()
|
||||
ctx.HasPriorFileSize = true
|
||||
} else if os.IsNotExist(err) {
|
||||
ctx.PriorFileSize = 0
|
||||
ctx.HasPriorFileSize = true
|
||||
}
|
||||
return realPath, nil
|
||||
}
|
||||
if !isMissingPutTargetError(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parentIndexPath := filepath.Dir(fullIndexPath)
|
||||
if parentIndexPath == "." || parentIndexPath == "" {
|
||||
parentIndexPath = "/"
|
||||
}
|
||||
parentReal, isDir, parentErr := idx.GetRealPath(parentIndexPath)
|
||||
if parentErr != nil {
|
||||
return "", parentErr
|
||||
}
|
||||
if !isDir {
|
||||
return "", fmt.Errorf("parent path is not a directory")
|
||||
}
|
||||
baseName := filepath.Base(strings.TrimSuffix(fullIndexPath, "/"))
|
||||
if baseName == "" || baseName == "." {
|
||||
return "", fmt.Errorf("invalid file path")
|
||||
}
|
||||
return filepath.Join(parentReal, baseName), nil
|
||||
return ctx
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -137,6 +116,16 @@ func finalizeQuotaPut(quotaCtx quota.UploadContext, tempPath, realPath, sourceNa
|
||||
_ = os.Remove(tempPath)
|
||||
return true, err
|
||||
}
|
||||
if info, err := os.Stat(realPath); err == nil {
|
||||
// Do not transfer setuid/setgid bits to new contents without preserving ownership.
|
||||
if err = os.Chmod(tempPath, info.Mode().Perm()); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return true, err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
_ = os.Remove(tempPath)
|
||||
return true, err
|
||||
}
|
||||
if err := fileutils.MoveFile(tempPath, realPath); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return true, err
|
||||
@@ -171,15 +160,19 @@ func isMissingPutTargetError(err error) bool {
|
||||
}
|
||||
|
||||
// putResourceWithQuota writes PUT body bytes with upload-style quota reserve/commit on the destination path.
|
||||
func putResourceWithQuota(w http.ResponseWriter, r *http.Request, d *Context, sourceName, fullIndexPath string) (int, error) {
|
||||
// boundIndexPath limits symlink resolution to the caller's scope ("" or "/" = source root only).
|
||||
func putResourceWithQuota(w http.ResponseWriter, r *http.Request, d *Context, sourceName, boundIndexPath, fullIndexPath string) (int, error) {
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not found")
|
||||
}
|
||||
realPath, err := resolvePutRealPath(idx, fullIndexPath)
|
||||
realPath, err := idx.GetRealPathForMutation(boundIndexPath, fullIndexPath)
|
||||
if err != nil {
|
||||
return sanitizePutPathError(err)
|
||||
}
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil && stat.IsDir() {
|
||||
return http.StatusMethodNotAllowed, errPutPathIsDirectory
|
||||
}
|
||||
|
||||
totalSize, hasTotalSize, err := parsePutTotalSize(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -270,6 +270,12 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *Context) (
|
||||
return ErrToStatus(err), err
|
||||
}
|
||||
|
||||
userScope, err := d.User.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userScope, path)
|
||||
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
|
||||
@@ -285,7 +291,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *Context) (
|
||||
deleteBytes = fileInfo.Size
|
||||
}
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
err = files.DeleteFiles(source, userScope, fullIndexPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
return ErrToStatus(err), err
|
||||
}
|
||||
@@ -312,12 +318,21 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
err = files.DeleteFiles(d.Share.SourcePath, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
userScope, err := d.ShareUser.GetScopeForSourceName(d.Share.SourcePath)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
// d.IndexPath is scope-relative; rebuild full index paths for the bound and target.
|
||||
shareRelPath := strings.TrimPrefix(d.Share.Path, userScope)
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userScope, d.IndexPath)
|
||||
boundIndexPath := utils.JoinScopedIndexPath(userScope, shareRelPath)
|
||||
err = files.DeleteFiles(d.Share.SourcePath, boundIndexPath, fullIndexPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
logger.Errorf("public delete handler: error deleting resource with error %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
|
||||
}
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
activity.RecordDelete(r, toActor(d), d.Share.GetSourceName(), fileInfo.Path)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
@@ -390,10 +405,12 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
Succeeded: make([]BulkDeleteItem, 0),
|
||||
Failed: make([]BulkDeleteItem, 0),
|
||||
}
|
||||
activityItems := make([]activity.BulkDeleteItem, 0, len(items))
|
||||
|
||||
// Process each item one at a time
|
||||
for _, item := range items {
|
||||
rawPath := item.Path
|
||||
activityPath := rawPath
|
||||
// Validate item
|
||||
if rawPath == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
@@ -476,8 +493,12 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
|
||||
// indexPath is scope-relative; rebuild full index paths for the bound and target.
|
||||
boundIndexPath := utils.JoinScopedIndexPath(userScope, withoutUserScope)
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userScope, indexPath)
|
||||
|
||||
// Delete the file/directory
|
||||
err = files.DeleteFiles(sourceName, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
err = files.DeleteFiles(sourceName, boundIndexPath, fullIndexPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
@@ -489,6 +510,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
}
|
||||
// Delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
activityPath = fileInfo.Path
|
||||
} else {
|
||||
// Regular user context - validate source and check user scope
|
||||
if item.Source == "" {
|
||||
@@ -501,7 +523,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
}
|
||||
|
||||
// Check user scope for this source
|
||||
_, err := filePermUser.GetScopeForSourceName(item.Source)
|
||||
userScope, err := filePermUser.GetScopeForSourceName(item.Source)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -554,7 +576,8 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
})
|
||||
continue
|
||||
}
|
||||
err = files.DeleteFiles(item.Source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userScope, sanitizedPath)
|
||||
err = files.DeleteFiles(item.Source, userScope, fullIndexPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -574,6 +597,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
loggedItem.Source = d.Share.GetSourceName()
|
||||
}
|
||||
response.Succeeded = append(response.Succeeded, loggedItem)
|
||||
activityItems = append(activityItems, activity.BulkDeleteItem{Source: loggedItem.Source, Path: activityPath})
|
||||
}
|
||||
|
||||
// Determine status code based on results
|
||||
@@ -582,8 +606,8 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
statusCode = http.StatusMultiStatus
|
||||
}
|
||||
|
||||
if len(response.Succeeded) > 0 {
|
||||
activity.RecordBulkDelete(r, toActor(d), bulkDeleteActivityItems(response.Succeeded))
|
||||
if len(activityItems) > 0 {
|
||||
activity.RecordBulkDelete(r, toActor(d), activityItems)
|
||||
}
|
||||
|
||||
return RenderJSON(w, r, response, statusCode)
|
||||
@@ -746,8 +770,19 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userscope, path)
|
||||
|
||||
// get scoped path
|
||||
realPath, _, _ := idx.GetRealPath(fullIndexPath)
|
||||
// Resolve for mutation: symlink components must stay inside the share path
|
||||
// for shares, or inside the user's scope otherwise — never unchecked. A
|
||||
// not-yet-existing leaf resolves via its nearest existing ancestor.
|
||||
mutationBound := userscope
|
||||
if d.Share.Hash != "" && d.Share.Path != "" {
|
||||
// d.Share.Path may be scope-relative or already scope-prefixed; normalize
|
||||
// to a full index path so it can serve as the containment bound.
|
||||
mutationBound = utils.JoinScopedIndexPath(userscope, strings.TrimPrefix(d.Share.Path, userscope))
|
||||
}
|
||||
realPath, err := idx.GetRealPathForMutation(mutationBound, fullIndexPath)
|
||||
if err != nil {
|
||||
return sanitizePutPathError(err)
|
||||
}
|
||||
|
||||
if d.Share.Hash == "" {
|
||||
filePerms, permErr := effectiveFilePerms(d, source)
|
||||
@@ -787,7 +822,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
dirOpts := fileOpts
|
||||
dirOpts.Path = fullIndexPath
|
||||
|
||||
err = files.WriteDirectory(dirOpts)
|
||||
err = files.WriteDirectory(mutationBound, dirOpts)
|
||||
if err != nil {
|
||||
logger.Debugf("error writing directory: %v", err)
|
||||
return ErrToStatus(err), err
|
||||
@@ -848,12 +883,14 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
}
|
||||
}()
|
||||
|
||||
// On the first chunk, check quota and conflicts
|
||||
if offset == 0 {
|
||||
// Reserve quota before writing any chunk (including resume at nonzero offset).
|
||||
if !quotaReserved {
|
||||
if quotaErr := checkUploadQuota(quotaCtx); quotaErr != nil {
|
||||
return renderQuotaError(w, r, quotaErr)
|
||||
}
|
||||
quotaReserved = true
|
||||
}
|
||||
if offset == 0 {
|
||||
// Check for file/folder conflicts for chunked uploads
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil {
|
||||
existingIsDir := stat.IsDir()
|
||||
@@ -1006,11 +1043,30 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
}
|
||||
|
||||
if !isContentUpload(r) {
|
||||
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
|
||||
sessionID := newQuotaSessionID()
|
||||
totalSize := r.ContentLength
|
||||
hasSize := totalSize >= 0
|
||||
quotaCtx := uploadQuotaContext(d, source, fullIndexPath, realPath, r, sessionID, totalSize, hasSize)
|
||||
quotaReserved := false
|
||||
defer func() {
|
||||
if quotaReserved {
|
||||
releaseUploadQuota(sessionID)
|
||||
}
|
||||
}()
|
||||
if quotaErr := checkUploadQuota(quotaCtx); quotaErr != nil {
|
||||
return renderQuotaError(w, r, quotaErr)
|
||||
}
|
||||
quotaReserved = true
|
||||
err = files.WriteFile(fileOpts.Source, mutationBound, fullIndexPath, r.Body)
|
||||
if err != nil {
|
||||
logger.Debugf("error writing file: %v", err)
|
||||
return ErrToStatus(err), err
|
||||
}
|
||||
if commitErr := commitUploadQuotaAfterMove(quotaCtx); commitErr != nil {
|
||||
quotaReserved = false
|
||||
return renderQuotaError(w, r, commitErr)
|
||||
}
|
||||
quotaReserved = false
|
||||
activity.RecordUpload(r, toActor(d), source, path, false)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
@@ -1100,6 +1156,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
}
|
||||
quotaReserved = false
|
||||
activity.RecordUpload(r, toActor(d), source, path, false)
|
||||
activeUploadSessions.release(realPath, sessionID)
|
||||
abandonSession = false
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
@@ -1206,7 +1263,11 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
|
||||
return http.StatusMethodNotAllowed, errPutPathIsDirectory
|
||||
}
|
||||
|
||||
return putResourceWithQuota(w, r, d, source, fullIndexPath)
|
||||
status, err := putResourceWithQuota(w, r, d, source, userScope, fullIndexPath)
|
||||
if err == nil && (status == http.StatusOK || status == 0) {
|
||||
activity.RecordFileModify(r, toActor(d), source, path)
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
|
||||
// publicPutHandler handles the PUT request for a public share.
|
||||
@@ -1247,7 +1308,11 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
if idx.Config.ReadOnly {
|
||||
return http.StatusForbidden, fmt.Errorf("source is read-only")
|
||||
}
|
||||
return putResourceWithQuota(w, r, d, sourceName, resolvedPath)
|
||||
status, err := putResourceWithQuota(w, r, d, sourceName, d.Share.Path, resolvedPath)
|
||||
if err == nil && (status == http.StatusOK || status == 0) {
|
||||
activity.RecordFileModify(r, toActor(d), sourceName, resolvedPath)
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
|
||||
// resourcePatchHandler performs a patch operation (e.g., move, copy, rename) on resources.
|
||||
@@ -1446,8 +1511,17 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
continue
|
||||
}
|
||||
|
||||
// Bound symlink resolution to the share path for shares or the user
|
||||
// scope otherwise, so linked paths cannot escape the caller's scope.
|
||||
boundSrc := userscopeSrc
|
||||
boundDst := userscopeDst
|
||||
if d.Share.Hash != "" {
|
||||
boundSrc = d.Share.Path
|
||||
boundDst = d.Share.Path
|
||||
}
|
||||
|
||||
// Get real paths (fullSrcIndexPath already includes user scope)
|
||||
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcIndexPath)
|
||||
realSrc, isSrcDir, err := srcIdx.GetRealPathScoped(boundSrc, fullSrcIndexPath)
|
||||
if err != nil {
|
||||
logger.Errorf("could not resolve source path: %v, fromPath: %v", err, clientFromPath)
|
||||
item.Message = "could not resolve source path"
|
||||
@@ -1461,10 +1535,14 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
continue
|
||||
}
|
||||
|
||||
// Check destination parent directory exists
|
||||
dstParentPath := filepath.Dir(cleanToPath)
|
||||
fullDstParentPath := utils.JoinScopedIndexPath(userscopeDst, dstParentPath)
|
||||
parentDir, _, err := dstIdx.GetRealPath(fullDstParentPath)
|
||||
// Resolve the destination: copy must reject escaping symlink leaves; move/rename
|
||||
// must replace an existing symlink entry without following it.
|
||||
var realDest string
|
||||
if req.Action == "copy" {
|
||||
realDest, err = dstIdx.GetRealPathForMutation(boundDst, fullDstIndexPath)
|
||||
} else {
|
||||
realDest, err = dstIdx.GetRealPathForRemoval(boundDst, fullDstIndexPath)
|
||||
}
|
||||
if err != nil {
|
||||
item.Message = "destination directory does not exist"
|
||||
if d.Share.Hash != "" {
|
||||
@@ -1476,7 +1554,6 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
|
||||
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
|
||||
continue
|
||||
}
|
||||
realDest := parentDir + "/" + filepath.Base(cleanToPath)
|
||||
|
||||
// Auto-rename if requested
|
||||
if req.Rename {
|
||||
@@ -1852,11 +1929,3 @@ func moveCopyWithClientPaths(item MoveCopyItem, clientFrom, clientTo string) Mov
|
||||
item.ToPath = clientTo
|
||||
return item
|
||||
}
|
||||
|
||||
func bulkDeleteActivityItems(items []BulkDeleteItem) []activity.BulkDeleteItem {
|
||||
out := make([]activity.BulkDeleteItem, len(items))
|
||||
for i, it := range items {
|
||||
out[i] = activity.BulkDeleteItem{Source: it.Source, Path: it.Path}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -49,6 +49,11 @@ func setupUploadHTTPTest(t *testing.T) (root string, user *users.User) {
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
|
||||
root = t.TempDir()
|
||||
// Mutation helpers resolve symlinks; keep the source root in resolved form
|
||||
// so expected paths match what the code produces (macOS /var -> /private/var).
|
||||
if resolved, rerr := filepath.EvalSymlinks(root); rerr == nil {
|
||||
root = resolved
|
||||
}
|
||||
sourceName := "uploads"
|
||||
sourcePath := root
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
func denyIfSourceReadOnly(sourceName string) (int, error) {
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx != nil && idx.Config.ReadOnly {
|
||||
return http.StatusForbidden, fmt.Errorf("source is read-only")
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setTestSourceReadOnly(sourceName string, readOnly bool) {
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
return
|
||||
}
|
||||
idx.Config.ReadOnly = readOnly
|
||||
}
|
||||
|
||||
func TestDenyIfSourceReadOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
indexing.SetTestIndex("ro-test", t.TempDir())
|
||||
t.Cleanup(func() { indexing.ClearTestIndices() })
|
||||
|
||||
if status, err := denyIfSourceReadOnly("ro-test"); status != 0 || err != nil {
|
||||
t.Fatalf("writable source: status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
setTestSourceReadOnly("ro-test", true)
|
||||
status, err := denyIfSourceReadOnly("ro-test")
|
||||
if status != http.StatusForbidden || err == nil {
|
||||
t.Fatalf("read-only source: status=%d err=%v", status, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "read-only") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func webDAVReadOnlyTestUser(source1Path string) *users.User {
|
||||
user := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{Username: "writer"},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: source1Path, Scope: "/"},
|
||||
},
|
||||
BackendSourcePermissions: webDAVPermsForPaths(true, true, true, true, source1Path),
|
||||
Version: users.SourcePermissionsMigrationVersion,
|
||||
}
|
||||
applyBackendSourcePerms(user, user.BackendSourcePermissions)
|
||||
return user
|
||||
}
|
||||
|
||||
func callWebDAVHandler(t *testing.T, user *users.User, method, path string, body io.Reader, extra func(*http.Request)) (int, int) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, "/dav/source1"+path, body)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", path)
|
||||
if extra != nil {
|
||||
extra(req)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
status, err := webDAVHandler(w, req, &requestContext{User: user})
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
if err != nil && status == 0 {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return w.Code, status
|
||||
}
|
||||
|
||||
func TestWebDAVReadOnlySourceEnforcement(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
user := webDAVReadOnlyTestUser(source1Path)
|
||||
|
||||
t.Run("read-only", func(t *testing.T) {
|
||||
setTestSourceReadOnly("source1", true)
|
||||
t.Cleanup(func() { setTestSourceReadOnly("source1", false) })
|
||||
|
||||
writeCases := []struct {
|
||||
method string
|
||||
path string
|
||||
setup func(*http.Request)
|
||||
}{
|
||||
{method: "MKCOL", path: "/public/rodir"},
|
||||
{method: http.MethodPut, path: "/public/ro-new.txt"},
|
||||
{method: http.MethodDelete, path: "/public/readme.txt"},
|
||||
{
|
||||
method: "COPY",
|
||||
path: "/public/readme.txt",
|
||||
setup: func(r *http.Request) {
|
||||
r.Header.Set("Destination", "/dav/source1/public/readme-copy.txt")
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "MOVE",
|
||||
path: "/public/readme.txt",
|
||||
setup: func(r *http.Request) {
|
||||
r.Header.Set("Destination", "/dav/source1/public/readme-moved.txt")
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range writeCases {
|
||||
t.Run(tc.method, func(t *testing.T) {
|
||||
var body io.Reader
|
||||
if tc.method == http.MethodPut {
|
||||
body = strings.NewReader("data")
|
||||
}
|
||||
code, _ := callWebDAVHandler(t, user, tc.method, tc.path, body, tc.setup)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{method: "PROPFIND", path: "/public"},
|
||||
{method: http.MethodGet, path: "/public/readme.txt"},
|
||||
} {
|
||||
t.Run(tc.method+"_allowed", func(t *testing.T) {
|
||||
code, _ := callWebDAVHandler(t, user, tc.method, tc.path, nil, nil)
|
||||
if code == http.StatusForbidden {
|
||||
t.Fatalf("%s should not be forbidden on read-only source", tc.method)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("writable", func(t *testing.T) {
|
||||
setTestSourceReadOnly("source1", false)
|
||||
code, _ := callWebDAVHandler(t, user, http.MethodPut, "/public/writable-new.txt", strings.NewReader("ok"), nil)
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("PUT on writable source: expected 201, got %d", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnarchivePreviewAllowsReadOnlyDestination(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
setTestSourceReadOnly("uploads", true)
|
||||
|
||||
archivePath := filepath.Join(root, "test.zip")
|
||||
f, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
w, err := zw.Create("file.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = w.Write([]byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"fromSource":"uploads","path":"/test.zip","destination":"/","preview":true}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/unarchive", strings.NewReader(body))
|
||||
status, err := unarchiveHandler(rec, req, &Context{User: user})
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("preview on read-only dest: status=%d err=%v body=%s", status, err, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnarchiveExtractionDeniedOnReadOnlyDestination(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
setTestSourceReadOnly("uploads", true)
|
||||
|
||||
archivePath := filepath.Join(root, "test.zip")
|
||||
f, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
w, err := zw.Create("file.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = w.Write([]byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"fromSource":"uploads","path":"/test.zip","destination":"/","preview":false}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/unarchive", strings.NewReader(body))
|
||||
status, err := unarchiveHandler(rec, req, &Context{User: user})
|
||||
if status != http.StatusForbidden || err == nil {
|
||||
t.Fatalf("extract on read-only dest: status=%d err=%v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyOfficeCallbackDeniedOnReadOnlySource(t *testing.T) {
|
||||
initStreamTestSources(t)
|
||||
indexing.SetTestIndex("default", "/default")
|
||||
t.Cleanup(func() { indexing.ClearTestIndices() })
|
||||
setTestSourceReadOnly("default", true)
|
||||
|
||||
const realPath = "/default/doc.docx"
|
||||
const docKey = "oo-ro-key"
|
||||
origFileInfo := files.FileInfoFasterFunc
|
||||
t.Cleanup(func() { files.FileInfoFasterFunc = origFileInfo })
|
||||
files.FileInfoFasterFunc = func(utils.FileOptions, *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return &iteminfo.ExtendedFileInfo{RealPath: realPath}, nil
|
||||
}
|
||||
utils.OnlyOfficeCache.Set(realPath, docKey)
|
||||
t.Cleanup(func() { utils.OnlyOfficeCache.Delete(realPath) })
|
||||
|
||||
origOnlyOffice := settings.Config.Integrations.OnlyOffice
|
||||
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = origOnlyOffice })
|
||||
settings.Config.Integrations.OnlyOffice.Url = "http://onlyoffice.test"
|
||||
|
||||
user := testUserWithSourcePerms("/default", users.SourceFilePermissions{
|
||||
View: true, Download: true, Modify: true,
|
||||
})
|
||||
d := &requestContext{User: user}
|
||||
callback := &OnlyOfficeCallback{
|
||||
Key: docKey,
|
||||
Status: onlyOfficeStatusDocumentClosedWithChanges,
|
||||
URL: "http://onlyoffice.test/cache/doc.docx",
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/office/callback?source=default&path=/doc.docx", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
if _, err := processOnlyOfficeCallback(rec, req, d, callback); err != nil {
|
||||
t.Fatalf("processOnlyOfficeCallback: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCreateDeniedOnReadOnlySource(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
setTestSourceReadOnly("uploads", true)
|
||||
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"fromSource":"uploads","paths":["/a.txt"],"destination":"/out.zip","format":"zip"}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/archive", strings.NewReader(body))
|
||||
status, err := archiveCreateHandler(rec, req, &Context{User: user})
|
||||
if status != http.StatusForbidden || err == nil {
|
||||
t.Fatalf("archive create on read-only: status=%d err=%v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnarchivePreviewSkipsReadOnlyDeleteAfterOnSource(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
setTestSourceReadOnly("uploads", true)
|
||||
|
||||
archivePath := filepath.Join(root, "test.zip")
|
||||
f, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
w, err := zw.Create("file.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = w.Write([]byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := `{"fromSource":"uploads","path":"/test.zip","destination":"/","preview":true,"deleteAfter":true}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources/unarchive", strings.NewReader(body))
|
||||
status, err := unarchiveHandler(rec, req, &Context{User: user})
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("preview with deleteAfter on read-only source: status=%d err=%v", status, err)
|
||||
}
|
||||
var preview json.RawMessage
|
||||
if err = json.Unmarshal(rec.Body.Bytes(), &preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = os.Stat(archivePath); err != nil {
|
||||
t.Fatal("archive should remain when preview does not delete")
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"searchResultsLimit": settings.Config.Server.SearchResultsLimit,
|
||||
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.Config.UserDefaults.UI.DarkMode,
|
||||
"singleClick": settings.Config.UserDefaults.Listing.SingleClick,
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"baseURL": settings.Config.Http.BaseURL,
|
||||
|
||||
@@ -232,7 +232,10 @@ func ValidateViewGrant(token string, d *Context, sourceName string) error {
|
||||
utils.ViewGrantsCache.Delete(token)
|
||||
return fmt.Errorf("view token expired")
|
||||
}
|
||||
viewCtx := contextForViewGrantValidation(d, grant.Source)
|
||||
viewCtx := d
|
||||
if d.Share.Hash == "" && (sourceName == "" || grant.Source != sourceName) {
|
||||
return fmt.Errorf("view token scope mismatch")
|
||||
}
|
||||
if !viewGrantScopeMatches(viewCtx, grant.Source, sourceName) {
|
||||
return fmt.Errorf("view token scope mismatch")
|
||||
}
|
||||
@@ -253,33 +256,6 @@ func ValidateViewGrant(token string, d *Context, sourceName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// contextForViewGrantValidation hydrates share metadata when optional-auth middleware
|
||||
// left only a hash stub on the context (anonymous public share visitors).
|
||||
func contextForViewGrantValidation(d *Context, grantSource string) *Context {
|
||||
if d == nil {
|
||||
return d
|
||||
}
|
||||
if d.Share.Hash != "" {
|
||||
return d
|
||||
}
|
||||
hash := strings.TrimSpace(grantSource)
|
||||
if hash == "" {
|
||||
return d
|
||||
}
|
||||
link, err := state.GetShare(hash)
|
||||
if err != nil || link.Hash == "" {
|
||||
return d
|
||||
}
|
||||
out := *d
|
||||
out.Share = link
|
||||
if out.ShareUser == nil {
|
||||
if u, err := state.UserForShareOwner(link); err == nil {
|
||||
out.ShareUser = &u
|
||||
}
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
func viewGrantScopeMatches(d *Context, grantSource, sourceName string) bool {
|
||||
expected := viewGrantScope(d, sourceName)
|
||||
if grantSource == expected {
|
||||
|
||||
@@ -226,6 +226,39 @@ func TestValidateViewGrantShareBinding(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateViewGrantRejectsShareTokenOnAuthenticatedRoute(t *testing.T) {
|
||||
initStreamTestSources(t)
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
"/srv": {Path: "/srv", Name: "srv"},
|
||||
}
|
||||
t.Cleanup(func() { settings.Config.Server.SourceMap = nil })
|
||||
|
||||
shareCtx := &requestContext{
|
||||
User: testUserWithView(1, "srv"),
|
||||
Share: share.Share{ShareColumns: share.ShareColumns{Hash: "abc123"}, SourcePath: "/srv"},
|
||||
}
|
||||
token, err := mintViewGrant(shareCtx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := &requestContext{
|
||||
User: &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: "bob"},
|
||||
ID: 2,
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: "/srv", Scope: "/", Permissions: users.DenyAllSourceFilePermissions()},
|
||||
},
|
||||
BackendSourcePermissions: map[string]users.SourceFilePermissions{
|
||||
"/srv": users.DenyAllSourceFilePermissions(),
|
||||
},
|
||||
Version: users.SourcePermissionsMigrationVersion,
|
||||
},
|
||||
}
|
||||
if err := ValidateViewGrant(token, denied, "srv"); err == nil {
|
||||
t.Fatal("expected share view token to be rejected on authenticated view route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachViewTokenForAllFileTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
initStreamTestSources(t)
|
||||
|
||||
@@ -283,6 +283,9 @@ func validateNonAdminSelfPatchWhich(which []string) error {
|
||||
if f == "" {
|
||||
return fmt.Errorf("which must not contain empty field names")
|
||||
}
|
||||
if strings.EqualFold(f, "PasskeyCredentials") {
|
||||
return fmt.Errorf("passkeys can only be managed through the passkey registration endpoints")
|
||||
}
|
||||
if strings.EqualFold(f, "Password") || strings.EqualFold(f, "OtpEnabled") {
|
||||
continue
|
||||
}
|
||||
@@ -305,7 +308,7 @@ func userPutOnlyNonAdminEditableFields(which []string) bool {
|
||||
if f == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(f, "Password") {
|
||||
if strings.EqualFold(f, "Password") || strings.EqualFold(f, "PasskeyCredentials") {
|
||||
return false
|
||||
}
|
||||
if _, ok := allowed[f]; !ok {
|
||||
|
||||
@@ -80,6 +80,12 @@ func TestValidateNonAdminSelfPatchWhich(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects passkey credentials field", func(t *testing.T) {
|
||||
if err := validateNonAdminSelfPatchWhich([]string{"passkeyCredentials"}); err == nil {
|
||||
t.Fatal("expected error for passkeyCredentials")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects privileged fields", func(t *testing.T) {
|
||||
for _, which := range [][]string{
|
||||
{"scopes"},
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestUsersPostPartialGlobalPermissions(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
settings.Config.UserDefaults.Account.Permissions.Share = true
|
||||
settings.Config.UserDefaults.Account.Permissions.Api = true
|
||||
settings.Config.UserDefaults.Account.Permissions.Realtime = true
|
||||
state.SetEffectiveUserDefaultsForTest(t, settings.Config.UserDefaults)
|
||||
|
||||
admin := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(admin, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin.Permissions = users.Permissions{Admin: true}
|
||||
if err := state.UpdateUser(admin, "", "permissions"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := []byte(`{"data":{"username":"newbie","password":"secret","loginMethod":"password","permissions":{"admin":true}}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := usersPostHandler(rec, req, &Context{User: admin})
|
||||
if err != nil || status != http.StatusCreated {
|
||||
t.Fatalf("usersPostHandler() status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
created, err := state.GetUserByUsername("newbie")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := users.Permissions{Admin: true, Share: true, Api: true, Realtime: true}
|
||||
if created.Permissions != want {
|
||||
t.Fatalf("permissions = %+v, want %+v", created.Permissions, want)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
)
|
||||
@@ -110,6 +111,11 @@ func beginPasskeyRegistrationHandler(w http.ResponseWriter, r *http.Request, d *
|
||||
if !auth.IsWebAuthnEnabled() {
|
||||
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
|
||||
}
|
||||
if d.User.LoginMethod == users.LoginMethodPassword && d.User.HasPasskeyMFA() {
|
||||
if err := checkOtpActorPassword(r, d, d.User.Username); err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
}
|
||||
|
||||
registerRequestOrigin(r)
|
||||
|
||||
@@ -144,6 +150,11 @@ func finishPasskeyRegistrationHandler(w http.ResponseWriter, r *http.Request, d
|
||||
if !auth.IsWebAuthnEnabled() {
|
||||
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
|
||||
}
|
||||
if d.User.LoginMethod == users.LoginMethodPassword && d.User.HasPasskeyMFA() {
|
||||
if err := checkOtpActorPassword(r, d, d.User.Username); err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
}
|
||||
|
||||
registerRequestOrigin(r)
|
||||
|
||||
@@ -188,6 +199,11 @@ func deletePasskeyCredentialHandler(w http.ResponseWriter, r *http.Request, d *C
|
||||
if !auth.IsWebAuthnEnabled() {
|
||||
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
|
||||
}
|
||||
if d.User.LoginMethod == users.LoginMethodPassword {
|
||||
if err := checkOtpActorPassword(r, d, d.User.Username); err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
}
|
||||
|
||||
credentialID := r.PathValue("id")
|
||||
if credentialID == "" {
|
||||
|
||||
@@ -56,6 +56,9 @@ type filteredFileSystem struct {
|
||||
user *users.User
|
||||
filePerms users.SourceFilePermissions
|
||||
httpReq *http.Request
|
||||
// skipRenameActivityFrom suppresses the move record for the hidden staging file a chunked
|
||||
// upload is assembled under; the upload is recorded once against the real destination.
|
||||
skipRenameActivityFrom string
|
||||
// Cache FileInfoFaster results per path to avoid redundant calls within the same request
|
||||
fileInfoCache map[string]*iteminfo.ExtendedFileInfo
|
||||
}
|
||||
@@ -208,6 +211,52 @@ func (ffs *filteredFileSystem) checkAccess(requestPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkScopePath verifies that a WebDAV request path cannot traverse a symlink
|
||||
// resolving outside the user's scope. Write targets may not exist yet (the
|
||||
// deepest existing ancestor is resolved); forRemoval keeps the leaf unresolved
|
||||
// so a symlink entry is removed rather than followed. Returns os.ErrPermission
|
||||
// on scope escapes.
|
||||
func (ffs *filteredFileSystem) checkScopePath(requestPath string, forRemoval bool) error {
|
||||
idx := indexing.GetIndex(ffs.source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source not found")
|
||||
}
|
||||
userScope, err := ffs.user.GetScopeForSourceName(ffs.source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userScope, strings.TrimPrefix(requestPath, "/"))
|
||||
if forRemoval {
|
||||
_, err = idx.GetRealPathForRemoval(userScope, fullIndexPath)
|
||||
} else {
|
||||
_, err = idx.GetRealPathForMutation(userScope, fullIndexPath)
|
||||
}
|
||||
if errors.Is(err, commonerrors.ErrPathEscapesScope) {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) requireCreatePermissionForNewFile(requestPath string) error {
|
||||
idx := indexing.GetIndex(ffs.source)
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source not found")
|
||||
}
|
||||
userScope, err := ffs.user.GetScopeForSourceName(ffs.source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userScope, strings.TrimPrefix(requestPath, "/"))
|
||||
realPath, err := idx.GetRealPathForMutation(userScope, fullIndexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, statErr := os.Stat(realPath); os.IsNotExist(statErr) {
|
||||
return fmt.Errorf("create permission required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filteredFile wraps a webdav.File and filters Readdir results based on FileInfoFaster
|
||||
type filteredFile struct {
|
||||
webdav.File
|
||||
@@ -359,6 +408,11 @@ func (ffs *filteredFileSystem) Mkdir(ctx context.Context, name string, perm os.F
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ffs.checkScopePath(name, false); err != nil {
|
||||
logger.Debugf("Mkdir: scope check denied for %s: %v", name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ffs.fs.Mkdir(ctx, name, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -390,6 +444,24 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
|
||||
logger.Debugf("OpenFile: write access denied for %s: %v", requestPath, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A symlinked component must not carry the write outside the scope
|
||||
if err := ffs.checkScopePath(requestPath, false); err != nil {
|
||||
logger.Debugf("OpenFile: scope check denied for %s: %v", requestPath, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if (flag&os.O_CREATE) != 0 && !ffs.sourceFilePerms().Create {
|
||||
if err := ffs.requireCreatePermissionForNewFile(requestPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A symlinked component must not carry the read outside the scope
|
||||
if err := ffs.checkScopePath(requestPath, false); errors.Is(err, os.ErrPermission) {
|
||||
logger.Debugf("OpenFile: read denied by scope for %s", requestPath)
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
}
|
||||
|
||||
var desiredMtime time.Time
|
||||
@@ -416,11 +488,14 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
|
||||
}
|
||||
principal := quota.PrincipalForUpload(ffs.user, 0, "")
|
||||
if state.HasApplicableQuotas(principal, sourcePath, fullIndexPath, "", 0) {
|
||||
realPath, pathErr := resolvePutRealPath(idx, fullIndexPath)
|
||||
realPath, pathErr := idx.GetRealPathForMutation(userScope, fullIndexPath)
|
||||
if pathErr != nil {
|
||||
_, clientErr := sanitizePutPathError(pathErr)
|
||||
return nil, clientErr
|
||||
}
|
||||
if stat, statErr := os.Stat(realPath); statErr == nil && stat.IsDir() {
|
||||
return nil, fmt.Errorf("path is a directory")
|
||||
}
|
||||
totalSize, hasTotalSize, sizeErr := parsePutTotalSize(ffs.httpReq)
|
||||
if sizeErr != nil {
|
||||
return nil, sizeErr
|
||||
@@ -495,10 +570,42 @@ func (ffs *filteredFileSystem) RemoveAll(ctx context.Context, requestPath string
|
||||
logger.Debugf("RemoveAll: access denied for %s: %v", requestPath, err)
|
||||
return err
|
||||
}
|
||||
if err := ffs.checkScopePath(requestPath, true); err != nil {
|
||||
logger.Debugf("RemoveAll: scope check denied for %s: %v", requestPath, err)
|
||||
return err
|
||||
}
|
||||
// The API delete path frees the deleted bytes from the quota counters. Do the same
|
||||
// here: a WebDAV DELETE, and the MOVE over an existing destination that the WebDAV
|
||||
// library implements as RemoveAll + Rename, otherwise leave the old bytes charged
|
||||
// until the next quota recount.
|
||||
//
|
||||
// The counters and the index speak source-relative paths, while requestPath is
|
||||
// relative to the user's scope, so join the scope the way OpenFile does.
|
||||
indexPath := requestPath
|
||||
if userScope, scopeErr := ffs.user.GetScopeForSourceName(ffs.source); scopeErr == nil {
|
||||
indexPath = utils.JoinScopedIndexPath(userScope, strings.TrimPrefix(requestPath, "/"))
|
||||
}
|
||||
|
||||
var deleteBytes int64
|
||||
if info, err := ffs.fs.Stat(ctx, requestPath); err != nil {
|
||||
logger.Debugf("RemoveAll: cannot stat %s before deleting, quota not adjusted: %v", requestPath, err)
|
||||
} else if info.IsDir() {
|
||||
deleteBytes, _ = state.IndexedPathBytes(ffs.source, indexPath, true)
|
||||
} else {
|
||||
deleteBytes = info.Size()
|
||||
}
|
||||
|
||||
if err := ffs.fs.RemoveAll(ctx, requestPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deleteBytes > 0 {
|
||||
if sourceInfo, ok := settings.Config.Server.NameToSource[ffs.source]; ok {
|
||||
if adjErr := state.ApplyAccountedUsageDelta(ffs.user, ffs.source, sourceInfo.Path, indexPath, "", -deleteBytes); adjErr != nil {
|
||||
logger.Warningf("quota adjust after webdav delete failed: %v", adjErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
activity.RecordWebDAVUser(ffs.httpReq, ffs.user, activitydb.Entry{
|
||||
EventType: activitydb.EventDelete,
|
||||
Source: ffs.source,
|
||||
@@ -527,9 +634,23 @@ func (ffs *filteredFileSystem) Rename(ctx context.Context, oldPath, newPath stri
|
||||
return err
|
||||
}
|
||||
|
||||
// Neither the moved entry nor the destination may resolve through a
|
||||
// symlink outside the scope.
|
||||
if err := ffs.checkScopePath(oldPath, true); err != nil {
|
||||
logger.Debugf("Rename: scope check denied for source %s: %v", oldPath, err)
|
||||
return err
|
||||
}
|
||||
if err := ffs.checkScopePath(newPath, false); err != nil {
|
||||
logger.Debugf("Rename: scope check denied for destination %s: %v", newPath, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ffs.fs.Rename(ctx, oldPath, newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if ffs.skipRenameActivityFrom != "" && oldPath == ffs.skipRenameActivityFrom {
|
||||
return nil
|
||||
}
|
||||
activity.RecordWebDAVUser(ffs.httpReq, ffs.user, activitydb.Entry{
|
||||
EventType: activitydb.EventMove,
|
||||
Source: ffs.source,
|
||||
@@ -545,6 +666,11 @@ func (ffs *filteredFileSystem) Rename(ctx context.Context, oldPath, newPath stri
|
||||
}
|
||||
|
||||
func (ffs *filteredFileSystem) Stat(ctx context.Context, requestPath string) (os.FileInfo, error) {
|
||||
// A symlinked component must not carry the stat outside the scope
|
||||
if err := ffs.checkScopePath(requestPath, false); errors.Is(err, os.ErrPermission) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
|
||||
// Try to get file info (uses cache if available, FileInfoFaster otherwise)
|
||||
_, err := ffs.getFileInfo(requestPath, false)
|
||||
if err == nil {
|
||||
@@ -581,6 +707,11 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
|
||||
if status, permErr := webDAVMethodPermission(r.Method, filePerms); permErr != nil {
|
||||
return status, permErr
|
||||
}
|
||||
if isWebDAVWriteMethod(r.Method) {
|
||||
if status, roErr := denyIfSourceReadOnly(source); roErr != nil {
|
||||
return status, roErr
|
||||
}
|
||||
}
|
||||
requestPath := utils.AddTrailingSlashIfNotExists(r.PathValue("path"))
|
||||
if !strings.HasPrefix(requestPath, "/") {
|
||||
requestPath = "/" + requestPath
|
||||
@@ -646,6 +777,18 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// ownCloud-style chunked upload: the client PUTs `<name>-chunk-<n>` files as ordinary uploads
|
||||
// and finishes by moving the last chunk to `<name>`; the chunks are concatenated here, and the
|
||||
// final placement is left to the handler below so locks and `Overwrite` keep working.
|
||||
if r.Method == "MOVE" {
|
||||
if handled, status, err := owncloudChunkedMove(w, r, wd, filteredFS, requestPath, scopePath, prefix); handled {
|
||||
if status != 0 {
|
||||
return status, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
if r.Method == "COPY" {
|
||||
srcRealPath := filepath.Join(scopePath, requestPath)
|
||||
sw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
@@ -687,8 +830,12 @@ func writeWebDAVOptions(w http.ResponseWriter, r *http.Request, ffs *filteredFil
|
||||
candidates = []string{http.MethodGet, http.MethodHead, "PROPFIND", http.MethodPut, http.MethodDelete, "COPY", "MOVE"}
|
||||
}
|
||||
|
||||
_, roErr := denyIfSourceReadOnly(ffs.source)
|
||||
allowed := []string{http.MethodOptions}
|
||||
for _, m := range candidates {
|
||||
if roErr != nil && isWebDAVWriteMethod(m) {
|
||||
continue
|
||||
}
|
||||
if _, err := webDAVMethodPermission(m, perms); err == nil {
|
||||
allowed = append(allowed, m)
|
||||
}
|
||||
@@ -701,6 +848,15 @@ func writeWebDAVOptions(w http.ResponseWriter, r *http.Request, ffs *filteredFil
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
func isWebDAVWriteMethod(method string) bool {
|
||||
switch method {
|
||||
case "MKCOL", http.MethodPut, http.MethodDelete, "COPY", "MOVE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func webDAVMethodPermission(method string, perms users.SourceFilePermissions) (int, error) {
|
||||
switch method {
|
||||
case "PROPFIND", http.MethodOptions:
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
databasequota "github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
|
||||
_ "github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb" // Import to register SQL driver
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
@@ -1146,3 +1147,81 @@ func TestWebDAV_OPTIONS(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A WebDAV DELETE must free the deleted bytes from the quota counters, the way the
|
||||
// API delete path does, or the old bytes stay charged until the next recount.
|
||||
func TestWebDAV_DeleteFreesQuota(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scope string
|
||||
webdavPath string
|
||||
quotaPath string
|
||||
indexPath string
|
||||
}{
|
||||
{
|
||||
name: "root scope",
|
||||
scope: "/",
|
||||
webdavPath: "/public/readme.txt",
|
||||
quotaPath: "/",
|
||||
indexPath: "/public/readme.txt",
|
||||
},
|
||||
{
|
||||
// The WebDAV path is relative to the user's scope; the quota and the
|
||||
// index are not, so this case pins the scope join.
|
||||
name: "scoped user",
|
||||
scope: "/public",
|
||||
webdavPath: "/readme.txt",
|
||||
quotaPath: "/public",
|
||||
indexPath: "/public/readme.txt",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
|
||||
user := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "quotauser",
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: source1Path, Scope: tc.scope},
|
||||
},
|
||||
BackendSourcePermissions: webDAVPermsForPaths(true, true, true, true, source1Path),
|
||||
Version: users.SourcePermissionsMigrationVersion,
|
||||
}
|
||||
applyBackendSourcePerms(user, user.BackendSourcePermissions)
|
||||
|
||||
folderQuota, err := state.CreateFolderQuota(source1Path, tc.quotaPath, 0, 1<<20, databasequota.MeterAccounted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
size := int64(len("public content"))
|
||||
if err = state.ApplyAccountedUsageDelta(user, "source1", source1Path, tc.indexPath, "", size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(folderQuota.ID); used != size {
|
||||
t.Fatalf("seeded counter = %d, want %d", used, size)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/dav/source1"+tc.webdavPath, nil)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", tc.webdavPath)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
status, err := webDAVHandler(w, req, &requestContext{User: user})
|
||||
if status != 0 && status != http.StatusOK && w.Code == 0 {
|
||||
w.Code = status
|
||||
}
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE status = %d (err %v), want %d", w.Code, err, http.StatusNoContent)
|
||||
}
|
||||
|
||||
if used, _ := state.GetQuotaCounterSnapshot(folderQuota.ID); used != 0 {
|
||||
t.Fatalf("counter after DELETE = %d, want 0", used)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/net/webdav"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
)
|
||||
|
||||
// Chunked uploads over WebDAV, in the shape rclone's `--owncloud-chunk-size` sends, which is what
|
||||
// clients need behind a proxy with a request size limit (`gtsteffaniak/filebrowser#2404`).
|
||||
//
|
||||
// Every chunk is an ordinary upload next to the destination, named `<name>-chunk-<n>` (some clients
|
||||
// hide them with a leading dot), and the transfer ends with a MOVE of the last chunk to `<name>`:
|
||||
//
|
||||
// PUT /dav/<source>/<dir>/<name>-chunk-0
|
||||
// PUT /dav/<source>/<dir>/<name>-chunk-1
|
||||
// MOVE /dav/<source>/<dir>/<name>-chunk-1 Destination: /dav/<source>/<dir>/<name>
|
||||
//
|
||||
// The chunks are concatenated on that final MOVE. Nothing is uploaded into a namespace, so a
|
||||
// transfer in flight is visible in the listing, and an abandoned one leaves its chunks behind (the
|
||||
// client's retry overwrites them); the alternative, a hidden staging namespace, cannot be told apart
|
||||
// from a real folder named `uploads`.
|
||||
//
|
||||
// The assembled file is placed by the WebDAV handler itself, so locks, `Overwrite`, the
|
||||
// 201/204/412 choice and the containment of the destination under the scope stay the library's job.
|
||||
const owncloudChunkSuffix = "-chunk-"
|
||||
|
||||
// owncloudChunk is one chunk file of an ownCloud-style upload, with the number in its name.
|
||||
type owncloudChunk struct {
|
||||
number int
|
||||
name string
|
||||
}
|
||||
|
||||
// owncloudChunkParts reports the destination an ownCloud chunk belongs to and which chunk it is:
|
||||
// `/dir/file.mp4-chunk-3` is chunk 3 of `/dir/file.mp4`.
|
||||
func owncloudChunkParts(requestPath string) (base string, number int, ok bool) {
|
||||
idx := strings.LastIndex(requestPath, owncloudChunkSuffix)
|
||||
if idx <= 0 || !isUploadChunkNumber(requestPath[idx+len(owncloudChunkSuffix):]) {
|
||||
return "", 0, false
|
||||
}
|
||||
number, convErr := strconv.Atoi(requestPath[idx+len(owncloudChunkSuffix):])
|
||||
if convErr != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return requestPath[:idx], number, true
|
||||
}
|
||||
|
||||
func isUploadChunkNumber(name string) bool {
|
||||
if name == "" || len(name) > 9 {
|
||||
return false
|
||||
}
|
||||
for _, c := range name {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// owncloudAssemblies tracks the real destination paths with an assembly in flight, so two
|
||||
// concurrent final MOVEs for the same file cannot interleave their chunks.
|
||||
var (
|
||||
owncloudAssembliesMu sync.Mutex
|
||||
owncloudAssemblies = map[string]struct{}{}
|
||||
)
|
||||
|
||||
func acquireOwncloudAssembly(realDest string) bool {
|
||||
owncloudAssembliesMu.Lock()
|
||||
defer owncloudAssembliesMu.Unlock()
|
||||
if _, ok := owncloudAssemblies[realDest]; ok {
|
||||
return false
|
||||
}
|
||||
owncloudAssemblies[realDest] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func releaseOwncloudAssembly(realDest string) {
|
||||
owncloudAssembliesMu.Lock()
|
||||
defer owncloudAssembliesMu.Unlock()
|
||||
delete(owncloudAssemblies, realDest)
|
||||
}
|
||||
|
||||
// owncloudChunkedMove handles the final MOVE of an ownCloud-style chunked upload, and reports
|
||||
// handled=false when the request is an ordinary move — including the move of a real file whose name
|
||||
// merely ends in `-chunk-<n>` and has no sibling chunks on disk.
|
||||
func owncloudChunkedMove(w http.ResponseWriter, r *http.Request, wd *webdav.Handler, ffs *filteredFileSystem, requestPath, scopePath, prefix string) (handled bool, status int, err error) {
|
||||
// the handler normalizes request paths with a trailing slash, the chunk number is before it
|
||||
sourceName := path.Base(strings.TrimSuffix(requestPath, "/"))
|
||||
base, moved, ok := owncloudChunkParts(strings.TrimSuffix(requestPath, "/"))
|
||||
if !ok {
|
||||
return false, 0, nil
|
||||
}
|
||||
base = path.Clean("/" + base)
|
||||
destPath, err := uploadDestinationPath(r, prefix)
|
||||
if err != nil {
|
||||
return true, http.StatusBadRequest, err
|
||||
}
|
||||
if destPath != base {
|
||||
// a dot-prefixed chunk name is the hidden variant of the same transfer
|
||||
if !strings.HasPrefix(path.Base(base), ".") || destPath != path.Join(path.Dir(base), strings.TrimPrefix(path.Base(base), ".")) {
|
||||
// the client is moving the chunk somewhere else: not a chunked upload
|
||||
return false, 0, nil
|
||||
}
|
||||
}
|
||||
realDest := filepath.Join(scopePath, destPath)
|
||||
realDir := filepath.Dir(realDest)
|
||||
chunks, err := collectOwncloudChunks(realDir, path.Base(destPath))
|
||||
if err != nil {
|
||||
return false, 0, nil
|
||||
}
|
||||
// the moved chunk closes the transfer: a higher number is a leftover of an earlier, longer upload
|
||||
// of the same name, and assembling it would mix two files together
|
||||
chunks = slices.DeleteFunc(chunks, func(chunk owncloudChunk) bool { return chunk.number > moved })
|
||||
// the chunk named in the request must be on disk, exactly under that name
|
||||
if !slices.ContainsFunc(chunks, func(chunk owncloudChunk) bool { return chunk.name == sourceName }) {
|
||||
return true, http.StatusNotFound, fmt.Errorf("chunk %s does not exist", sourceName)
|
||||
}
|
||||
if len(chunks) < 2 {
|
||||
return false, 0, nil
|
||||
}
|
||||
for i, chunk := range chunks {
|
||||
if i > 0 && chunk.number == chunks[i-1].number {
|
||||
return true, http.StatusConflict, fmt.Errorf("chunked upload has two files for chunk %d", chunk.number)
|
||||
}
|
||||
if chunk.number != i {
|
||||
return true, http.StatusConflict, fmt.Errorf("chunked upload is missing chunk %d of %d", i, chunks[len(chunks)-1].number)
|
||||
}
|
||||
}
|
||||
|
||||
// assembling deletes the chunks afterwards, and every chunk must be one the user may touch;
|
||||
// check both up front so a denied transfer touches nothing
|
||||
if !ffs.sourceFilePerms().Delete {
|
||||
return true, http.StatusForbidden, fmt.Errorf("delete permission required")
|
||||
}
|
||||
destDir := path.Dir(destPath)
|
||||
if err = ffs.checkScopePath(destDir, false); err != nil {
|
||||
return true, http.StatusForbidden, fmt.Errorf("chunked upload destination denied: %w", err)
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
if accessErr := ffs.checkAccess(path.Join(destDir, chunk.name)); accessErr != nil {
|
||||
return true, http.StatusForbidden, fmt.Errorf("chunked upload cannot access chunk %s: %w", chunk.name, accessErr)
|
||||
}
|
||||
}
|
||||
|
||||
if !acquireOwncloudAssembly(realDest) {
|
||||
return true, http.StatusConflict, fmt.Errorf("another chunked upload is already assembling %s", destPath)
|
||||
}
|
||||
defer releaseOwncloudAssembly(realDest)
|
||||
|
||||
// The assembled file is a random hidden name in the destination directory: it must not be able to
|
||||
// collide with something the user already has (O_EXCL), and staying in that directory is what lets
|
||||
// the library below move it with a plain rename.
|
||||
tmp, err := os.CreateTemp(realDir, "."+path.Base(destPath)+".assembled-*")
|
||||
if err != nil {
|
||||
return true, http.StatusInternalServerError, err
|
||||
}
|
||||
realAssembled := tmp.Name()
|
||||
if chmodErr := tmp.Chmod(fileutils.EffectiveFilePerm()); chmodErr != nil {
|
||||
tmp.Close()
|
||||
os.Remove(realAssembled)
|
||||
return true, http.StatusInternalServerError, chmodErr
|
||||
}
|
||||
written, err := writeOwncloudChunks(realDir, chunks, tmp)
|
||||
if err != nil {
|
||||
os.Remove(realAssembled)
|
||||
return true, http.StatusInternalServerError, err
|
||||
}
|
||||
if total := r.Header.Get("OC-Total-Length"); total != "" {
|
||||
want, convErr := strconv.ParseInt(total, 10, 64)
|
||||
if convErr != nil || want != written {
|
||||
// keep the chunks: the client can retry the move with the missing bytes
|
||||
os.Remove(realAssembled)
|
||||
return true, http.StatusBadRequest, fmt.Errorf("assembled %d bytes, but OC-Total-Length is %q", written, total)
|
||||
}
|
||||
}
|
||||
|
||||
// Move the assembled file the way any other WebDAV move is done: the library cleans the paths,
|
||||
// confirms the locks, honours `Overwrite` and answers 201/204/412 itself.
|
||||
assembled := path.Join(destDir, filepath.Base(realAssembled))
|
||||
// the library move of the hidden staging file is not a user-visible move: skip its activity
|
||||
// record and record one upload for the real destination instead
|
||||
ffs.skipRenameActivityFrom = assembled
|
||||
original := r.URL.Path
|
||||
r.URL.Path = prefix + assembled
|
||||
sw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
wd.ServeHTTP(sw, r)
|
||||
r.URL.Path = original
|
||||
if sw.status >= 300 {
|
||||
os.Remove(realAssembled)
|
||||
return true, 0, nil
|
||||
}
|
||||
activity.RecordWebDAVUser(r, ffs.user, activitydb.Entry{
|
||||
EventType: activitydb.EventUpload,
|
||||
Source: ffs.source,
|
||||
Path: destPath,
|
||||
Details: activitydb.Details{
|
||||
Source: ffs.source,
|
||||
Path: destPath,
|
||||
},
|
||||
})
|
||||
for _, chunk := range chunks {
|
||||
if err := os.Remove(filepath.Join(realDir, chunk.name)); err != nil {
|
||||
logger.Debugf("webdav chunked upload: could not remove chunk %s: %v", chunk.name, err)
|
||||
}
|
||||
}
|
||||
logger.Debugf("webdav chunked upload: assembled %d chunks (%d bytes) into %s", len(chunks), written, destPath)
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
// collectOwncloudChunks lists the chunk files of one destination, lowest number first. Clients name
|
||||
// them `<name>-chunk-<n>`; a leading dot is accepted as well, as some clients hide them.
|
||||
//
|
||||
// shortcut: two real files `x-chunk-0` and `x-chunk-1` do get concatenated when the last of them is
|
||||
// moved to `x`; the protocol has no way to tell that intent apart, so the client's naming wins.
|
||||
func collectOwncloudChunks(realDir, baseName string) ([]owncloudChunk, error) {
|
||||
entries, err := os.ReadDir(realDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefixes := []string{baseName + owncloudChunkSuffix, "." + baseName + owncloudChunkSuffix}
|
||||
var chunks []owncloudChunk
|
||||
for _, entry := range entries {
|
||||
// only regular files are chunks: a symlink or device named `x-chunk-0` must never
|
||||
// be read into the assembled file (Type()==0 means "unknown", openOwncloudChunks
|
||||
// re-checks those against Lstat)
|
||||
if t := entry.Type(); t != 0 && !t.IsRegular() {
|
||||
continue
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
number, found := strings.CutPrefix(entry.Name(), p)
|
||||
if !found || !isUploadChunkNumber(number) {
|
||||
continue
|
||||
}
|
||||
n, _ := strconv.Atoi(number)
|
||||
chunks = append(chunks, owncloudChunk{number: n, name: entry.Name()})
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Slice(chunks, func(i, j int) bool {
|
||||
return chunks[i].number < chunks[j].number
|
||||
})
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func writeOwncloudChunks(realDir string, chunks []owncloudChunk, out *os.File) (int64, error) {
|
||||
var written int64
|
||||
for _, chunk := range chunks {
|
||||
in, err := openOwncloudChunk(filepath.Join(realDir, chunk.name))
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return written, err
|
||||
}
|
||||
n, copyErr := io.Copy(out, in)
|
||||
in.Close()
|
||||
written += n
|
||||
if copyErr != nil {
|
||||
out.Close()
|
||||
return written, copyErr
|
||||
}
|
||||
}
|
||||
if err := out.Sync(); err != nil {
|
||||
out.Close()
|
||||
return written, err
|
||||
}
|
||||
return written, out.Close()
|
||||
}
|
||||
|
||||
// openOwncloudChunk opens a chunk for reading without ever following a symlink: the entry is
|
||||
// Lstat'ed first (a symlink swap between ReadDir and open is caught by the SameFile check),
|
||||
// and on platforms that support it the open itself uses O_NOFOLLOW.
|
||||
func openOwncloudChunk(name string) (*os.File, error) {
|
||||
lstat, err := os.Lstat(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !lstat.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("chunk %s is not a regular file", filepath.Base(name))
|
||||
}
|
||||
in, err := os.OpenFile(name, os.O_RDONLY|owncloudNoFollowFlag, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stat, err := in.Stat()
|
||||
if err == nil && (!stat.Mode().IsRegular() || !os.SameFile(lstat, stat)) {
|
||||
err = fmt.Errorf("chunk %s changed while opening", filepath.Base(name))
|
||||
}
|
||||
if err != nil {
|
||||
in.Close()
|
||||
return nil, err
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// uploadDestinationPath reads the Destination header of the final MOVE and returns the path inside
|
||||
// the source, the way the WebDAV client sees it (relative to the user's scope). The path is cleaned
|
||||
// the same way golang.org/x/net/webdav cleans it, i.e. anchored at the scope: a client cannot walk
|
||||
// out of its scope with `..`, and the caller gets exactly the path the library would have used.
|
||||
func uploadDestinationPath(r *http.Request, prefix string) (string, error) {
|
||||
raw := r.Header.Get("Destination")
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("missing Destination header")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid Destination header")
|
||||
}
|
||||
rest, ok := strings.CutPrefix(u.Path, prefix)
|
||||
if !ok || (rest != "" && rest[0] != '/') {
|
||||
return "", fmt.Errorf("destination is outside this source")
|
||||
}
|
||||
rest = path.Clean("/" + rest)
|
||||
if rest == "/" || rest == "." {
|
||||
return "", fmt.Errorf("destination is empty")
|
||||
}
|
||||
return rest, nil
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
activityrec "github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
)
|
||||
|
||||
// chunkedUploadUser builds a user with the given permissions on source1.
|
||||
func chunkedUploadUser(source1Path, scope string, perms users.SourceFilePermissions) *users.User {
|
||||
return &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "chunked-uploader",
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: source1Path, Scope: scope},
|
||||
},
|
||||
BackendSourcePermissions: map[string]users.SourceFilePermissions{source1Path: perms},
|
||||
Version: users.SourcePermissionsMigrationVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func chunkedUploadFullPerms() users.SourceFilePermissions {
|
||||
return users.SourceFilePermissions{View: true, Download: true, Modify: true, Create: true, Delete: true}
|
||||
}
|
||||
|
||||
// doSource1WebDAV calls the handler the way the router does, and reports the status the client
|
||||
// would see (the handler writes success codes itself and returns error codes).
|
||||
func doSource1WebDAV(t *testing.T, user *users.User, method, path string, body io.Reader, headers map[string]string) int {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, "/dav/source1"+path, body)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", path)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
status, err := webDAVHandler(w, req, &requestContext{User: user})
|
||||
if status != 0 && status != http.StatusOK && (w.Code == 0 || w.Code == http.StatusOK) {
|
||||
w.Code = status
|
||||
}
|
||||
if status >= 400 && err == nil {
|
||||
t.Fatalf("%s %s: status %d without an error", method, path, status)
|
||||
}
|
||||
return w.Code
|
||||
}
|
||||
|
||||
func mustPutChunk(t *testing.T, user *users.User, dir, name string, number int, content string) {
|
||||
t.Helper()
|
||||
path := fmt.Sprintf("%s/%s-chunk-%d", dir, name, number)
|
||||
if got := doSource1WebDAV(t, user, http.MethodPut, path, strings.NewReader(content), nil); got != http.StatusCreated {
|
||||
t.Fatalf("PUT %s: got %d, want 201", path, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_AssemblesChunks(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
|
||||
// rclone's shape: the chunks are ordinary uploads next to the destination
|
||||
parts := []string{"hello ", "big ", "world"}
|
||||
for i, part := range parts {
|
||||
mustPutChunk(t, user, "/public", "video.mp4", i, part)
|
||||
}
|
||||
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/video.mp4-chunk-2", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/video.mp4",
|
||||
"OC-Total-Length": "15",
|
||||
})
|
||||
if got != http.StatusCreated {
|
||||
t.Fatalf("MOVE of the last chunk: got %d, want 201", got)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(source1Path, "public", "video.mp4"))
|
||||
if err != nil {
|
||||
t.Fatalf("assembled file not written: %v", err)
|
||||
}
|
||||
if string(content) != "hello big world" {
|
||||
t.Errorf("assembled content = %q, want %q", content, "hello big world")
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(source1Path, "public"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), "-chunk-") || strings.Contains(entry.Name(), ".assembled-") {
|
||||
t.Errorf("transfer artifact left behind: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_RefusesGapsAndWrongTotal(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
|
||||
// a missing chunk must not be assembled into a shorter file
|
||||
mustPutChunk(t, user, "/public", "gap.bin", 0, "abc")
|
||||
mustPutChunk(t, user, "/public", "gap.bin", 2, "def")
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/gap.bin-chunk-2", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/gap.bin",
|
||||
"OC-Total-Length": "6",
|
||||
})
|
||||
if got != http.StatusConflict {
|
||||
t.Errorf("MOVE with a missing chunk: got %d, want 409", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(source1Path, "public", "gap.bin")); !os.IsNotExist(err) {
|
||||
t.Errorf("a truncated file was written anyway: %v", err)
|
||||
}
|
||||
|
||||
// ... and neither must a total length that disagrees with the chunks
|
||||
mustPutChunk(t, user, "/public", "short.bin", 0, "abc")
|
||||
mustPutChunk(t, user, "/public", "short.bin", 1, "def")
|
||||
got = doSource1WebDAV(t, user, "MOVE", "/public/short.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/short.bin",
|
||||
"OC-Total-Length": "99",
|
||||
})
|
||||
if got != http.StatusBadRequest {
|
||||
t.Errorf("MOVE with a wrong total length: got %d, want 400", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_HonoursOverwrite(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
existing := filepath.Join(source1Path, "public", "existing.bin")
|
||||
if err := os.WriteFile(existing, []byte("do not lose me"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mustPutChunk(t, user, "/public", "existing.bin", 0, "new ")
|
||||
mustPutChunk(t, user, "/public", "existing.bin", 1, "content")
|
||||
// without `Overwrite: T` the destination must not be replaced (the WebDAV library's rule)
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/existing.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/existing.bin",
|
||||
})
|
||||
if got != http.StatusPreconditionFailed {
|
||||
t.Errorf("MOVE onto an existing file without Overwrite: got %d, want 412", got)
|
||||
}
|
||||
if content, _ := os.ReadFile(existing); string(content) != "do not lose me" {
|
||||
t.Errorf("existing file was replaced anyway: %q", content)
|
||||
}
|
||||
|
||||
got = doSource1WebDAV(t, user, "MOVE", "/public/existing.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/existing.bin",
|
||||
"Overwrite": "T",
|
||||
})
|
||||
if got != http.StatusNoContent {
|
||||
t.Errorf("MOVE with Overwrite: T: got %d, want 204", got)
|
||||
}
|
||||
if content, _ := os.ReadFile(existing); string(content) != "new content" {
|
||||
t.Errorf("overwrite did not land: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_CannotWriteOutsideTheScope(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
// the user is confined to /public
|
||||
user := chunkedUploadUser(source1Path, "/public", chunkedUploadFullPerms())
|
||||
|
||||
// both the request path and the Destination climb out with `..`: this is the shape that enters the
|
||||
// chunked move itself (they have to agree, or it is an ordinary move)
|
||||
mustPutChunk(t, user, "/public/..", "escape.bin", 0, "ESCAPED")
|
||||
mustPutChunk(t, user, "/public/..", "escape.bin", 1, "!")
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/../escape.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/../escape.bin",
|
||||
})
|
||||
if got != http.StatusCreated {
|
||||
t.Fatalf("chunked move with a climbing path: got %d, want 201", got)
|
||||
}
|
||||
// the cleaned paths are anchored at the scope, so the file lands inside it and not above it,
|
||||
// and it is the concatenation of both chunks (which only the chunked move produces)
|
||||
content, err := os.ReadFile(filepath.Join(source1Path, "public", "escape.bin"))
|
||||
if err != nil {
|
||||
t.Errorf("assembled file is not inside the user's scope: %v", err)
|
||||
} else if string(content) != "ESCAPED!" {
|
||||
t.Errorf("assembled content = %q, want %q", content, "ESCAPED!")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(source1Path, "public", "escape.bin-chunk-0")); !os.IsNotExist(statErr) {
|
||||
t.Errorf("a chunk was left behind: %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(source1Path, "escape.bin")); !os.IsNotExist(statErr) {
|
||||
t.Errorf("the climbing path escaped the scope: %v", statErr)
|
||||
}
|
||||
|
||||
// a Destination that resolves somewhere else entirely is not a chunked upload, and the library
|
||||
// refuses to move the chunk there
|
||||
mustPutChunk(t, user, "", "other.bin", 0, "a")
|
||||
mustPutChunk(t, user, "", "other.bin", 1, "b")
|
||||
got = doSource1WebDAV(t, user, "MOVE", "/other.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/../../" + strings.Repeat("../", 20) + "tmp/fb_escape_probe.txt",
|
||||
})
|
||||
if got < 300 {
|
||||
t.Errorf("MOVE to a destination outside the source: got %d, want an error", got)
|
||||
}
|
||||
if _, statErr := os.Stat("/tmp/fb_escape_probe.txt"); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("path traversal escaped the scope (status %d): %v", got, statErr)
|
||||
}
|
||||
|
||||
// and no half-assembled file is left behind anywhere the user can see
|
||||
entries, err := os.ReadDir(filepath.Join(source1Path, "public"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), ".assembled") {
|
||||
t.Errorf("assembled temp file left behind: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_PlainMoveStillWorks(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
|
||||
// a real file whose name ends in -chunk-N, with no siblings, is an ordinary file
|
||||
doSource1WebDAV(t, user, http.MethodPut, "/public/notes-chunk-1", strings.NewReader("just notes"), nil)
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/notes-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/notes.txt",
|
||||
})
|
||||
if got != http.StatusCreated {
|
||||
t.Fatalf("MOVE of a plain file: got %d, want 201", got)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(source1Path, "public", "notes.txt"))
|
||||
if err != nil || string(content) != "just notes" {
|
||||
t.Errorf("plain move lost the file: %v %q", err, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_DoesNotDestroyCollidingTemp(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
|
||||
// a user file that looks like our staging name must survive the upload
|
||||
colliding := filepath.Join(source1Path, "public", ".video.mp4.assembled")
|
||||
if err := os.WriteFile(colliding, []byte("user data"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustPutChunk(t, user, "/public", "video.mp4", 0, "hello ")
|
||||
mustPutChunk(t, user, "/public", "video.mp4", 1, "world")
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/video.mp4-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/video.mp4",
|
||||
})
|
||||
if got != http.StatusCreated {
|
||||
t.Fatalf("MOVE: got %d, want 201", got)
|
||||
}
|
||||
if content, err := os.ReadFile(colliding); err != nil || string(content) != "user data" {
|
||||
t.Errorf("the colliding file was destroyed: %v %q", err, content)
|
||||
}
|
||||
if content, err := os.ReadFile(filepath.Join(source1Path, "public", "video.mp4")); err != nil || string(content) != "hello world" {
|
||||
t.Errorf("assembled content = %q, %v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_RecordsUploadNotTempMove(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
|
||||
mustPutChunk(t, user, "/public", "audit.bin", 0, "aa")
|
||||
mustPutChunk(t, user, "/public", "audit.bin", 1, "bb")
|
||||
if got := doSource1WebDAV(t, user, "MOVE", "/public/audit.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/audit.bin",
|
||||
}); got != http.StatusCreated {
|
||||
t.Fatalf("MOVE: got %d, want 201", got)
|
||||
}
|
||||
|
||||
activityrec.FlushNow()
|
||||
rows, _, err := state.ListActivity(activitydb.QueryFilter{Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var uploads, moves int
|
||||
for _, row := range rows {
|
||||
if strings.Contains(row.Path, ".assembled-") {
|
||||
t.Errorf("activity recorded the hidden staging name: %s %q", row.EventType, row.Path)
|
||||
}
|
||||
switch row.EventType {
|
||||
case activitydb.EventUpload:
|
||||
uploads++
|
||||
if row.Path != "/public/audit.bin" {
|
||||
t.Errorf("upload recorded for %q, want /public/audit.bin", row.Path)
|
||||
}
|
||||
case activitydb.EventMove:
|
||||
moves++
|
||||
}
|
||||
}
|
||||
if uploads != 1 {
|
||||
t.Errorf("expected exactly one upload entry, got %d", uploads)
|
||||
}
|
||||
if moves != 0 {
|
||||
t.Errorf("expected no move entries for the staging file, got %d", moves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_SkipsSymlinkChunks(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
|
||||
outside := filepath.Join(t.TempDir(), "outside-secret.txt")
|
||||
if err := os.WriteFile(outside, []byte("OUTSIDE-SECRET"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(source1Path, "public", "link.bin-chunk-0")); err != nil {
|
||||
t.Skipf("cannot create symlink: %v", err)
|
||||
}
|
||||
mustPutChunk(t, user, "/public", "link.bin", 1, "REAL")
|
||||
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/link.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/link.bin",
|
||||
})
|
||||
if got != http.StatusCreated {
|
||||
t.Fatalf("MOVE: got %d, want 201", got)
|
||||
}
|
||||
// with the symlink ignored only one chunk remains: this is a plain move of that chunk,
|
||||
// and the outside file's content must never land in the destination
|
||||
content, err := os.ReadFile(filepath.Join(source1Path, "public", "link.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(content), "OUTSIDE") {
|
||||
t.Errorf("symlink chunk content leaked into the destination: %q", content)
|
||||
}
|
||||
if string(content) != "REAL" {
|
||||
t.Errorf("destination = %q, want %q", content, "REAL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_RequiresDeletePermission(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", users.SourceFilePermissions{
|
||||
View: true, Download: true, Modify: true, Create: true,
|
||||
})
|
||||
|
||||
mustPutChunk(t, user, "/public", "nodelete.bin", 0, "aa")
|
||||
mustPutChunk(t, user, "/public", "nodelete.bin", 1, "bb")
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/nodelete.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/nodelete.bin",
|
||||
})
|
||||
if got != http.StatusForbidden {
|
||||
t.Errorf("MOVE without delete permission: got %d, want 403", got)
|
||||
}
|
||||
// a denied transfer touches nothing
|
||||
for _, name := range []string{"nodelete.bin-chunk-0", "nodelete.bin-chunk-1"} {
|
||||
if _, err := os.Stat(filepath.Join(source1Path, "public", name)); err != nil {
|
||||
t.Errorf("chunk %s was touched: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(source1Path, "public", "nodelete.bin")); !os.IsNotExist(err) {
|
||||
t.Errorf("destination was written anyway: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_DeniedChunkTouchesNothing(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
|
||||
mustPutChunk(t, user, "/public", "denied.bin", 0, "aa")
|
||||
mustPutChunk(t, user, "/public", "denied.bin", 1, "bb")
|
||||
if err := state.DenyUser(source1Path, utils.IndexPathFromNormalized("/public/denied.bin-chunk-0", false), "chunked-uploader"); err != nil {
|
||||
t.Fatalf("failed to set up access rule: %v", err)
|
||||
}
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/denied.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/denied.bin",
|
||||
})
|
||||
if got != http.StatusForbidden {
|
||||
t.Errorf("MOVE over a denied chunk: got %d, want 403", got)
|
||||
}
|
||||
for _, name := range []string{"denied.bin-chunk-0", "denied.bin-chunk-1"} {
|
||||
if _, err := os.Stat(filepath.Join(source1Path, "public", name)); err != nil {
|
||||
t.Errorf("chunk %s was touched: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(source1Path, "public", "denied.bin")); !os.IsNotExist(err) {
|
||||
t.Errorf("destination was written anyway: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_MovedChunkMustExist(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
|
||||
mustPutChunk(t, user, "/public", "miss.bin", 0, "aa")
|
||||
mustPutChunk(t, user, "/public", "miss.bin", 1, "bb")
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/miss.bin-chunk-5", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/miss.bin",
|
||||
})
|
||||
if got != http.StatusNotFound {
|
||||
t.Errorf("MOVE of a chunk that does not exist: got %d, want 404", got)
|
||||
}
|
||||
for _, name := range []string{"miss.bin-chunk-0", "miss.bin-chunk-1"} {
|
||||
if _, err := os.Stat(filepath.Join(source1Path, "public", name)); err != nil {
|
||||
t.Errorf("chunk %s was touched: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(source1Path, "public", "miss.bin")); !os.IsNotExist(err) {
|
||||
t.Errorf("destination was written anyway: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwncloudAssemblyInFlight(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "file.bin")
|
||||
if !acquireOwncloudAssembly(dest) {
|
||||
t.Fatal("first acquire should succeed")
|
||||
}
|
||||
if acquireOwncloudAssembly(dest) {
|
||||
t.Error("second acquire for the same destination should fail while one is in flight")
|
||||
}
|
||||
if !acquireOwncloudAssembly(dest + ".other") {
|
||||
t.Error("a different destination should not be blocked")
|
||||
}
|
||||
releaseOwncloudAssembly(dest + ".other")
|
||||
releaseOwncloudAssembly(dest)
|
||||
if !acquireOwncloudAssembly(dest) {
|
||||
t.Error("acquire after release should succeed")
|
||||
}
|
||||
releaseOwncloudAssembly(dest)
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUploadPathParsing(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
base string
|
||||
ok bool
|
||||
chunkNumber string
|
||||
validChunkNum bool
|
||||
}{
|
||||
{"/public/video.mp4-chunk-0", "/public/video.mp4", true, "0", true},
|
||||
{"/video.mp4-chunk-12", "/video.mp4", true, "12", true},
|
||||
{"/video.mp4-chunk-", "", false, "", false},
|
||||
{"/chunk-1", "", false, "1", true},
|
||||
{"/public/readme.txt", "", false, "", false},
|
||||
{"/public/video.mp4-chunk-00x", "", false, "00x", false},
|
||||
{"/public/video.mp4-chunk-1234567890", "", false, "1234567890", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
base, _, ok := owncloudChunkParts(tc.path)
|
||||
if base != tc.base || ok != tc.ok {
|
||||
t.Errorf("owncloudChunkParts(%q) = %q, %v; want %q, %v", tc.path, base, ok, tc.base, tc.ok)
|
||||
}
|
||||
if got := isUploadChunkNumber(tc.chunkNumber); got != tc.validChunkNum {
|
||||
t.Errorf("isUploadChunkNumber(%q) = %v, want %v", tc.chunkNumber, got, tc.validChunkNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_UploadDestinationPathIsCleaned(t *testing.T) {
|
||||
cases := []struct {
|
||||
destination string
|
||||
want string
|
||||
}{
|
||||
{"/dav/source1/public/file.txt", "/public/file.txt"},
|
||||
{"/dav/source1/public/../../tmp/x.txt", "/tmp/x.txt"},
|
||||
{"/dav/source1/public/" + strings.Repeat("../", 25) + "tmp/x.txt", "/tmp/x.txt"},
|
||||
{"/dav/source1/", ""},
|
||||
{"/dav/source1", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest("MOVE", "/dav/source1/public/x-chunk-0", nil)
|
||||
req.Header.Set("Destination", tc.destination)
|
||||
got, err := uploadDestinationPath(req, "/dav/source1")
|
||||
if tc.want == "" {
|
||||
if err == nil {
|
||||
t.Errorf("uploadDestinationPath(%q) = %q, want an error", tc.destination, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil || got != tc.want {
|
||||
t.Errorf("uploadDestinationPath(%q) = %q, %v; want %q", tc.destination, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_IgnoresChunksAboveTheMovedOne(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
|
||||
// chunk 2 is a leftover of an earlier, longer upload of the same name: the client closes this
|
||||
// transfer with chunk 1, so only 0 and 1 belong to it
|
||||
mustPutChunk(t, user, "/public", "mixed.bin", 0, "AAA")
|
||||
mustPutChunk(t, user, "/public", "mixed.bin", 1, "BBB")
|
||||
mustPutChunk(t, user, "/public", "mixed.bin", 2, "CCC")
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/mixed.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/mixed.bin",
|
||||
})
|
||||
if got != http.StatusCreated {
|
||||
t.Fatalf("MOVE: got %d, want 201", got)
|
||||
}
|
||||
if content, err := os.ReadFile(filepath.Join(source1Path, "public", "mixed.bin")); err != nil || string(content) != "AAABBB" {
|
||||
t.Errorf("assembled %q, %v; want %q", content, err, "AAABBB")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(source1Path, "public", "mixed.bin-chunk-2")); err != nil {
|
||||
t.Errorf("the chunk of another transfer was consumed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAV_OwncloudChunkedUpload_RejectsTwoFilesForOneChunk(t *testing.T) {
|
||||
source1Path, _ := setupWebDAVTestEnv(t)
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
user := chunkedUploadUser(source1Path, "/", chunkedUploadFullPerms())
|
||||
|
||||
mustPutChunk(t, user, "/public", "dup.bin", 0, "aaa")
|
||||
mustPutChunk(t, user, "/public", "dup.bin", 1, "bbb")
|
||||
// a hidden copy of a chunk, which clients that hide them would also leave behind
|
||||
if err := os.WriteFile(filepath.Join(source1Path, "public", ".dup.bin-chunk-0"), []byte("zzz"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := doSource1WebDAV(t, user, "MOVE", "/public/dup.bin-chunk-1", nil, map[string]string{
|
||||
"Destination": "/dav/source1/public/dup.bin",
|
||||
})
|
||||
if got != http.StatusConflict {
|
||||
t.Errorf("MOVE with two files for one chunk: got %d, want 409", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(source1Path, "public", "dup.bin")); !os.IsNotExist(err) {
|
||||
t.Errorf("a file was written from an ambiguous transfer: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package web
|
||||
|
||||
import "syscall"
|
||||
|
||||
// owncloudNoFollowFlag is OR'ed into the open flags for chunk files so a symlink can never
|
||||
// be followed even if it is swapped in between the Lstat check and the open.
|
||||
const owncloudNoFollowFlag = syscall.O_NOFOLLOW
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build windows
|
||||
|
||||
package web
|
||||
|
||||
// Windows has no O_NOFOLLOW; openOwncloudChunk's Lstat + SameFile check still prevents a
|
||||
// swapped-in symlink from being read as a chunk.
|
||||
const owncloudNoFollowFlag = 0
|
||||
@@ -9,15 +9,23 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// getCreatedTime returns the file's birth time via statx, or nil if the path is empty
|
||||
// or the filesystem does not report one. The link itself is queried only when info
|
||||
// describes a symlink; otherwise the target is followed, matching how info was obtained.
|
||||
// getCreatedTime returns the file's birth time when available. Directory listings
|
||||
// embed birth time in statxFileInfo; other paths use statx on realPath (or nil).
|
||||
func getCreatedTime(info os.FileInfo, realPath string) *time.Time {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
if sxi, ok := info.(*statxFileInfo); ok {
|
||||
return sxi.birthTime()
|
||||
}
|
||||
if _, ok := info.(*fstatatFileInfo); ok {
|
||||
return nil
|
||||
}
|
||||
if realPath == "" {
|
||||
return nil
|
||||
}
|
||||
flags := 0
|
||||
if info != nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
flags = unix.AT_SYMLINK_NOFOLLOW
|
||||
}
|
||||
var stx unix.Statx_t
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//go:build linux
|
||||
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// statxFileInfo is an os.FileInfo built from a single statx result (directory listing).
|
||||
type statxFileInfo struct {
|
||||
name string
|
||||
mode uint16
|
||||
size uint64
|
||||
blocks uint64
|
||||
nlink uint32
|
||||
ino uint64
|
||||
mtime unix.StatxTimestamp
|
||||
btime unix.StatxTimestamp
|
||||
hasBtime bool
|
||||
}
|
||||
|
||||
func (s *statxFileInfo) Name() string { return s.name }
|
||||
func (s *statxFileInfo) Size() int64 { return int64(s.size) }
|
||||
func (s *statxFileInfo) Mode() os.FileMode { return modeFromStatx(s.mode) }
|
||||
func (s *statxFileInfo) ModTime() time.Time { return time.Unix(s.mtime.Sec, int64(s.mtime.Nsec)) }
|
||||
func (s *statxFileInfo) IsDir() bool { return s.mode&unix.S_IFMT == unix.S_IFDIR }
|
||||
func (s *statxFileInfo) Sys() interface{} { return s }
|
||||
|
||||
func (s *statxFileInfo) birthTime() *time.Time {
|
||||
if !s.hasBtime || s.btime.Sec <= 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(s.btime.Sec, int64(s.btime.Nsec))
|
||||
return &t
|
||||
}
|
||||
|
||||
// unixFileDetails provides size/nlink/ino from the statx result so callers never
|
||||
// need a second path-based stat.
|
||||
func (s *statxFileInfo) unixFileDetails(useLogicalSize bool) (uint64, uint64, uint64) {
|
||||
if useLogicalSize {
|
||||
return s.size, uint64(s.nlink), s.ino
|
||||
}
|
||||
return s.blocks * 512, uint64(s.nlink), s.ino
|
||||
}
|
||||
|
||||
func modeFromStatx(mode uint16) os.FileMode {
|
||||
fm := os.FileMode(mode & 0o777)
|
||||
switch mode & 0o170000 {
|
||||
case unix.S_IFBLK:
|
||||
fm |= os.ModeDevice
|
||||
case unix.S_IFCHR:
|
||||
fm |= os.ModeDevice | os.ModeCharDevice
|
||||
case unix.S_IFDIR:
|
||||
fm |= os.ModeDir
|
||||
case unix.S_IFIFO:
|
||||
fm |= os.ModeNamedPipe
|
||||
case unix.S_IFLNK:
|
||||
fm |= os.ModeSymlink
|
||||
case unix.S_IFSOCK:
|
||||
fm |= os.ModeSocket
|
||||
}
|
||||
if mode&unix.S_ISGID != 0 {
|
||||
fm |= os.ModeSetgid
|
||||
}
|
||||
if mode&unix.S_ISUID != 0 {
|
||||
fm |= os.ModeSetuid
|
||||
}
|
||||
if mode&unix.S_ISVTX != 0 {
|
||||
fm |= os.ModeSticky
|
||||
}
|
||||
return fm
|
||||
}
|
||||
|
||||
const statxDirListingMask = unix.STATX_TYPE | unix.STATX_MODE | unix.STATX_NLINK |
|
||||
unix.STATX_INO | unix.STATX_SIZE | unix.STATX_BLOCKS | unix.STATX_MTIME | unix.STATX_BTIME
|
||||
|
||||
var (
|
||||
statxDirListingOnce sync.Once
|
||||
statxDirListingSupported bool
|
||||
)
|
||||
|
||||
func statxDirListingAvailable(dirFd int) bool {
|
||||
statxDirListingOnce.Do(func() {
|
||||
var stx unix.Statx_t
|
||||
err := unix.Statx(dirFd, ".", unix.AT_SYMLINK_NOFOLLOW, statxDirListingMask, &stx)
|
||||
// Only trust statx when the probe fully succeeds; seccomp/container
|
||||
// filters (EPERM/EACCES) and missing kernel support (ENOSYS/EOPNOTSUPP)
|
||||
// must fall back to fstatat.
|
||||
statxDirListingSupported = err == nil
|
||||
})
|
||||
return statxDirListingSupported
|
||||
}
|
||||
|
||||
// dirStatParallelThreshold is the minimum directory size where concurrent
|
||||
// per-entry stat calls pay off.
|
||||
const dirStatParallelThreshold = 256
|
||||
|
||||
// dirStatMaxWorkers bounds concurrent statx/fstatat calls so wide directories on
|
||||
// slow filesystems do not overwhelm the kernel or I/O stack.
|
||||
const dirStatMaxWorkers = 16
|
||||
|
||||
func readDirFileInfos(dir *os.File) ([]os.FileInfo, error) {
|
||||
names, err := dir.Readdirnames(-1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dirFd := int(dir.Fd())
|
||||
useStatx := statxDirListingAvailable(dirFd)
|
||||
|
||||
if len(names) < dirStatParallelThreshold {
|
||||
infos := make([]os.FileInfo, 0, len(names))
|
||||
for _, name := range names {
|
||||
fi, err := statDirEntry(dirFd, name, useStatx)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
infos = append(infos, fi)
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
return statDirEntriesParallel(dirFd, names, useStatx)
|
||||
}
|
||||
|
||||
// statDirEntriesParallel stats each name relative to dirFd across a bounded
|
||||
// worker pool. Entry order matches names; entries that vanished are skipped.
|
||||
func statDirEntriesParallel(dirFd int, names []string, useStatx bool) ([]os.FileInfo, error) {
|
||||
workers := runtime.GOMAXPROCS(0) * 2
|
||||
if workers > dirStatMaxWorkers {
|
||||
workers = dirStatMaxWorkers
|
||||
}
|
||||
if workers > len(names) {
|
||||
workers = len(names)
|
||||
}
|
||||
|
||||
results := make([]os.FileInfo, len(names))
|
||||
var next atomic.Int64
|
||||
var errMu sync.Mutex
|
||||
var firstErr error
|
||||
recordErr := func(err error) {
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
errMu.Unlock()
|
||||
}
|
||||
hasErr := func() bool {
|
||||
errMu.Lock()
|
||||
defer errMu.Unlock()
|
||||
return firstErr != nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
if hasErr() {
|
||||
return
|
||||
}
|
||||
i := int(next.Add(1)) - 1
|
||||
if i >= len(names) {
|
||||
return
|
||||
}
|
||||
fi, err := statDirEntry(dirFd, names[i], useStatx)
|
||||
switch {
|
||||
case err == nil:
|
||||
results[i] = fi
|
||||
case os.IsNotExist(err):
|
||||
// Entry disappeared between readdir and stat; leave nil.
|
||||
default:
|
||||
recordErr(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
infos := make([]os.FileInfo, 0, len(names))
|
||||
for _, fi := range results {
|
||||
if fi != nil {
|
||||
infos = append(infos, fi)
|
||||
}
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
func statDirEntry(dirFd int, name string, useStatx bool) (os.FileInfo, error) {
|
||||
if useStatx {
|
||||
var stx unix.Statx_t
|
||||
err := unix.Statx(dirFd, name, unix.AT_SYMLINK_NOFOLLOW, statxDirListingMask, &stx)
|
||||
if err == nil {
|
||||
return &statxFileInfo{
|
||||
name: name,
|
||||
mode: stx.Mode,
|
||||
size: stx.Size,
|
||||
blocks: stx.Blocks,
|
||||
nlink: stx.Nlink,
|
||||
ino: stx.Ino,
|
||||
mtime: stx.Mtime,
|
||||
btime: stx.Btime,
|
||||
hasBtime: stx.Mask&unix.STATX_BTIME != 0,
|
||||
}, nil
|
||||
}
|
||||
if err != unix.ENOSYS && err != syscall.EOPNOTSUPP {
|
||||
return nil, err
|
||||
}
|
||||
// statx probe succeeded but this call lacks support (e.g. filesystem
|
||||
// restrictions): fall through to fstatat for this entry.
|
||||
}
|
||||
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstatat(dirFd, name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fstatatFileInfo{name: name, stat: st}, nil
|
||||
}
|
||||
|
||||
// fstatatFileInfo is used when the kernel does not support statx (no birth time).
|
||||
type fstatatFileInfo struct {
|
||||
name string
|
||||
stat unix.Stat_t
|
||||
}
|
||||
|
||||
func (f *fstatatFileInfo) Name() string { return f.name }
|
||||
func (f *fstatatFileInfo) Size() int64 { return f.stat.Size }
|
||||
func (f *fstatatFileInfo) Mode() os.FileMode { return modeFromStatx(uint16(f.stat.Mode)) }
|
||||
func (f *fstatatFileInfo) ModTime() time.Time {
|
||||
return time.Unix(int64(f.stat.Mtim.Sec), int64(f.stat.Mtim.Nsec))
|
||||
}
|
||||
func (f *fstatatFileInfo) IsDir() bool { return f.stat.Mode&unix.S_IFMT == unix.S_IFDIR }
|
||||
func (f *fstatatFileInfo) Sys() interface{} { return f }
|
||||
|
||||
// unixFileDetails provides size/nlink/ino from the fstatat result so callers
|
||||
// never need a second path-based stat.
|
||||
func (f *fstatatFileInfo) unixFileDetails(useLogicalSize bool) (uint64, uint64, uint64) {
|
||||
if useLogicalSize {
|
||||
return uint64(f.stat.Size), uint64(f.stat.Nlink), f.stat.Ino
|
||||
}
|
||||
return uint64(f.stat.Blocks) * 512, uint64(f.stat.Nlink), f.stat.Ino
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//go:build linux
|
||||
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func writeFixtureDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(dir, "target")
|
||||
if err := os.WriteFile(target, []byte("y"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestReadDirFileInfos_MatchesLstat(t *testing.T) {
|
||||
dir := writeFixtureDir(t)
|
||||
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
entries, err := readDirFileInfos(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
byName := make(map[string]os.FileInfo, len(entries))
|
||||
for _, e := range entries {
|
||||
byName[e.Name()] = e
|
||||
}
|
||||
|
||||
wantNames := []string{"a.txt", "sub", "target", "link"}
|
||||
if len(byName) != len(wantNames) {
|
||||
t.Fatalf("expected %d entries, got %d", len(wantNames), len(byName))
|
||||
}
|
||||
|
||||
for _, name := range wantNames {
|
||||
got, ok := byName[name]
|
||||
if !ok {
|
||||
t.Fatalf("missing entry %s", name)
|
||||
}
|
||||
want, err := os.Lstat(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Mode() != want.Mode() {
|
||||
t.Errorf("%s: mode got %v want %v", name, got.Mode(), want.Mode())
|
||||
}
|
||||
if got.Size() != want.Size() {
|
||||
t.Errorf("%s: size got %d want %d", name, got.Size(), want.Size())
|
||||
}
|
||||
if !got.ModTime().Equal(want.ModTime()) {
|
||||
t.Errorf("%s: mtime got %v want %v", name, got.ModTime(), want.ModTime())
|
||||
}
|
||||
if got.IsDir() != want.IsDir() {
|
||||
t.Errorf("%s: isDir got %v want %v", name, got.IsDir(), want.IsDir())
|
||||
}
|
||||
// Concrete type must match the probed kernel support.
|
||||
if statxDirListingSupported {
|
||||
if _, ok := got.(*statxFileInfo); !ok {
|
||||
t.Errorf("%s: expected statxFileInfo, got %T", name, got)
|
||||
}
|
||||
} else if _, ok := got.(*fstatatFileInfo); !ok {
|
||||
t.Errorf("%s: expected fstatatFileInfo, got %T", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDirFileInfos_CreatedTimeWithoutExtraPathStatx(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "created.txt")
|
||||
if err := os.WriteFile(path, []byte("z"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
entries, err := readDirFileInfos(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fi os.FileInfo
|
||||
for _, e := range entries {
|
||||
if e.Name() == "created.txt" {
|
||||
fi = e
|
||||
break
|
||||
}
|
||||
}
|
||||
if fi == nil {
|
||||
t.Fatal("missing created.txt entry")
|
||||
}
|
||||
|
||||
// An empty realPath proves created time comes from the listing stat itself.
|
||||
created := getCreatedTime(fi, "")
|
||||
switch e := fi.(type) {
|
||||
case *statxFileInfo:
|
||||
if e.hasBtime {
|
||||
if created == nil {
|
||||
t.Error("expected non-nil created time when STATX_BTIME is present")
|
||||
}
|
||||
} else if created != nil {
|
||||
t.Errorf("expected nil created without STATX_BTIME, got %v", created)
|
||||
}
|
||||
case *fstatatFileInfo:
|
||||
if created != nil {
|
||||
t.Errorf("fstatat has no birth time, expected nil created, got %v", created)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected FileInfo type %T", fi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatDirEntry_MissingName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
for _, useStatx := range []bool{true, false} {
|
||||
_, err := statDirEntry(int(f.Fd()), "does-not-exist", useStatx)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Fatalf("useStatx=%v: expected IsNotExist error, got %v", useStatx, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileInfoIsDirFileTypeMask(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mode uint32
|
||||
want bool
|
||||
}{
|
||||
{"dir", unix.S_IFDIR | 0o755, true},
|
||||
{"regular", unix.S_IFREG | 0o644, false},
|
||||
{"symlink", unix.S_IFLNK | 0o777, false},
|
||||
{"socket", unix.S_IFSOCK | 0o777, false},
|
||||
{"blockdev", unix.S_IFBLK | 0o660, false},
|
||||
{"chardev", unix.S_IFCHR | 0o660, false},
|
||||
{"fifo", unix.S_IFIFO | 0o644, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
sfi := &statxFileInfo{name: tc.name, mode: uint16(tc.mode)}
|
||||
if got := sfi.IsDir(); got != tc.want {
|
||||
t.Errorf("statxFileInfo %s mode %#o: IsDir=%v want %v", tc.name, tc.mode, got, tc.want)
|
||||
}
|
||||
ffi := &fstatatFileInfo{name: tc.name, stat: unix.Stat_t{Mode: tc.mode}}
|
||||
if got := ffi.IsDir(); got != tc.want {
|
||||
t.Errorf("fstatatFileInfo %s mode %#o: IsDir=%v want %v", tc.name, tc.mode, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileDetails_StatxAndFstatat(t *testing.T) {
|
||||
sfi := &statxFileInfo{
|
||||
name: "f",
|
||||
size: 4097,
|
||||
blocks: 16,
|
||||
nlink: 2,
|
||||
ino: 4242,
|
||||
}
|
||||
realSize, nlink, ino, ok := getFileDetails(sfi.Sys(), "", false)
|
||||
if !ok || realSize != 16*512 || nlink != 2 || ino != 4242 {
|
||||
t.Errorf("statx disk-usage details: got (%d,%d,%d,%v) want (8192,2,4242,true)", realSize, nlink, ino, ok)
|
||||
}
|
||||
realSize, nlink, ino, ok = getFileDetails(sfi.Sys(), "", true)
|
||||
if !ok || realSize != 4097 || nlink != 2 || ino != 4242 {
|
||||
t.Errorf("statx logical details: got (%d,%d,%d,%v) want (4097,2,4242,true)", realSize, nlink, ino, ok)
|
||||
}
|
||||
|
||||
ffi := &fstatatFileInfo{
|
||||
name: "f",
|
||||
stat: unix.Stat_t{Size: 123, Blocks: 8, Nlink: 3, Ino: 77},
|
||||
}
|
||||
realSize, nlink, ino, ok = getFileDetails(ffi.Sys(), "", false)
|
||||
if !ok || realSize != 8*512 || nlink != 3 || ino != 77 {
|
||||
t.Errorf("fstatat disk-usage details: got (%d,%d,%d,%v) want (4096,3,77,true)", realSize, nlink, ino, ok)
|
||||
}
|
||||
realSize, nlink, ino, ok = getFileDetails(ffi.Sys(), "", true)
|
||||
if !ok || realSize != 123 || nlink != 3 || ino != 77 {
|
||||
t.Errorf("fstatat logical details: got (%d,%d,%d,%v) want (123,3,77,true)", realSize, nlink, ino, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCreatedTime_Fallbacks(t *testing.T) {
|
||||
if got := getCreatedTime(&fstatatFileInfo{name: "f"}, ""); got != nil {
|
||||
t.Errorf("fstatatFileInfo: expected nil created, got %v", got)
|
||||
}
|
||||
noBtime := &statxFileInfo{name: "f", hasBtime: false}
|
||||
if got := getCreatedTime(noBtime, ""); got != nil {
|
||||
t.Errorf("statxFileInfo without BTIME: expected nil created, got %v", got)
|
||||
}
|
||||
withBtime := &statxFileInfo{
|
||||
name: "f",
|
||||
hasBtime: true,
|
||||
btime: unix.StatxTimestamp{Sec: 1700000000, Nsec: 42},
|
||||
}
|
||||
got := getCreatedTime(withBtime, "")
|
||||
if got == nil || !got.Equal(time.Unix(1700000000, 42)) {
|
||||
t.Errorf("statxFileInfo with BTIME: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDirFileInfos_Parallel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
const count = dirStatParallelThreshold + 44
|
||||
for i := 0; i < count; i++ {
|
||||
name := filepath.Join(dir, fmt.Sprintf("file_%04d", i))
|
||||
if err := os.WriteFile(name, []byte{byte(i % 251)}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
entries, err := readDirFileInfos(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != count {
|
||||
t.Fatalf("expected %d entries, got %d", count, len(entries))
|
||||
}
|
||||
for _, e := range entries {
|
||||
want, err := os.Lstat(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.Size() != want.Size() || e.IsDir() != want.IsDir() || !e.ModTime().Equal(want.ModTime()) {
|
||||
t.Errorf("%s: mismatch vs lstat", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package indexing
|
||||
|
||||
import "os"
|
||||
|
||||
func readDirFileInfos(dir *os.File) ([]os.FileInfo, error) {
|
||||
return dir.Readdir(-1)
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
// TestFetchExtendedAttributes tests the fetchExtendedAttributes helper function
|
||||
@@ -287,7 +291,7 @@ func TestProcessFileItem(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
itemInfo, size, shouldCount, bubblesUp := idx.processFileItem(
|
||||
tt.file, tt.combinedPath, tt.opts, tt.scanner,
|
||||
tt.file, tt.combinedPath, tt.opts, tt.scanner, nil,
|
||||
)
|
||||
|
||||
if itemInfo == nil {
|
||||
@@ -308,6 +312,265 @@ func TestProcessFileItem(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchDirectoryCache_cachesIndexedFiles verifies the file metadata map is
|
||||
// built from the same GetMetadataInfo query as the folder preview map.
|
||||
func TestFetchDirectoryCache_cachesIndexedFiles(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
subdirMap, cachedFiles := idx.fetchDirectoryCache("/", Options{
|
||||
SkipExtendedAttrs: false,
|
||||
Recursive: false,
|
||||
})
|
||||
|
||||
if _, ok := subdirMap["/subdir/"]; !ok {
|
||||
t.Errorf("expected hasPreview entry for /subdir/, got %v", subdirMap)
|
||||
}
|
||||
if len(cachedFiles) != 2 {
|
||||
t.Fatalf("expected 2 cached files at root, got %d", len(cachedFiles))
|
||||
}
|
||||
f1 := cachedFiles["file1.txt"]
|
||||
if f1 == nil || f1.Size != 100 || f1.Type != "file" {
|
||||
t.Errorf("unexpected cached file1.txt: %+v", f1)
|
||||
}
|
||||
|
||||
// Recursive listings never use the cache (they index fresh metadata).
|
||||
_, cachedFiles = idx.fetchDirectoryCache("/", Options{Recursive: true})
|
||||
if cachedFiles != nil {
|
||||
t.Errorf("expected nil cachedFiles for recursive opts, got %v", cachedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessFileItem_CachedMetadataReuse verifies unchanged files reuse cached
|
||||
// type/preview/created metadata instead of re-detecting, while changed files
|
||||
// fall back to fresh detection.
|
||||
func TestProcessFileItem_CachedMetadataReuse(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
now := time.Now()
|
||||
created := now.Add(-time.Hour)
|
||||
mockFile := &mockFileInfo{
|
||||
name: "track.flac",
|
||||
size: 1024,
|
||||
modTime: now,
|
||||
}
|
||||
opts := Options{Recursive: false, SkipExtendedAttrs: false}
|
||||
|
||||
cachedFiles := map[string]*iteminfo.ItemInfo{
|
||||
"track.flac": {
|
||||
Name: "track.flac",
|
||||
Size: 1024,
|
||||
ModTime: now,
|
||||
Created: &created,
|
||||
Type: "audio/x-cached",
|
||||
HasPreview: true,
|
||||
},
|
||||
}
|
||||
|
||||
itemInfo, _, _, _ := idx.processFileItem(mockFile, "/test/", opts, nil, cachedFiles)
|
||||
if itemInfo.Type != "audio/x-cached" {
|
||||
t.Errorf("expected cached type, got %s", itemInfo.Type)
|
||||
}
|
||||
if !itemInfo.HasPreview {
|
||||
t.Error("expected cached HasPreview=true")
|
||||
}
|
||||
if itemInfo.Created == nil || !itemInfo.Created.Equal(created) {
|
||||
t.Errorf("expected cached Created %v, got %v", created, itemInfo.Created)
|
||||
}
|
||||
|
||||
// Size change invalidates the cache: fresh detection runs and preview is
|
||||
// recomputed (the nonexistent real path yields no album art).
|
||||
stale := map[string]*iteminfo.ItemInfo{
|
||||
"track.flac": {
|
||||
Name: "track.flac",
|
||||
Size: 512,
|
||||
ModTime: now,
|
||||
Type: "audio/x-cached",
|
||||
HasPreview: true,
|
||||
},
|
||||
}
|
||||
itemInfo, _, _, _ = idx.processFileItem(mockFile, "/test/", opts, nil, stale)
|
||||
if itemInfo.Type == "audio/x-cached" {
|
||||
t.Error("expected fresh type detection after size change")
|
||||
}
|
||||
if itemInfo.HasPreview {
|
||||
t.Error("expected HasPreview=false after fresh detection")
|
||||
}
|
||||
|
||||
// Mtime change also invalidates the cache.
|
||||
staleMtime := map[string]*iteminfo.ItemInfo{
|
||||
"track.flac": {
|
||||
Name: "track.flac",
|
||||
Size: 1024,
|
||||
ModTime: now.Add(-time.Minute),
|
||||
Type: "audio/x-cached",
|
||||
HasPreview: true,
|
||||
},
|
||||
}
|
||||
itemInfo, _, _, _ = idx.processFileItem(mockFile, "/test/", opts, nil, staleMtime)
|
||||
if itemInfo.Type == "audio/x-cached" {
|
||||
t.Error("expected fresh type detection after mtime change")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDirInfoCore_IndexableListing verifies the second listing returned by
|
||||
// GetDirInfoCore contains only items indexing would persist (hidden and
|
||||
// viewable-only entries are displayed but not indexed), and that
|
||||
// persistDirectoryListing writes exactly those rows plus the folder size.
|
||||
func TestGetDirInfoCore_IndexableListing(t *testing.T) {
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, _, err = dbsql.NewIndexDB("test_indexable", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"visible.txt", ".hidden.txt", "viewable.txt"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
idx := &Index{
|
||||
Source: settings.Source{
|
||||
Name: "test_indexable",
|
||||
Path: root,
|
||||
Config: settings.SourceConfig{
|
||||
UseLogicalSize: true,
|
||||
ResolvedRules: settings.ResolvedRulesConfig{
|
||||
// Viewable in listings but excluded from indexing.
|
||||
FileNames: map[string]settings.ConditionalRule{
|
||||
"viewable.txt": {Viewable: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
db: indexDB,
|
||||
scanUpdatedPaths: make(map[string]bool),
|
||||
folderSizes: make(map[string]uint64),
|
||||
folderSizesUnsynced: make(map[string]struct{}),
|
||||
}
|
||||
// Clean any rows left by a previous run for this source.
|
||||
if err := idx.db.DeleteItem(idx.Name, "/", true); err != nil {
|
||||
t.Fatalf("cleanup DeleteItem: %v", err)
|
||||
}
|
||||
|
||||
dir, err := os.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dir.Close()
|
||||
stat, err := dir.Stat()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
opts := Options{
|
||||
CheckViewable: true,
|
||||
ShowHidden: true,
|
||||
SkipExtendedAttrs: false,
|
||||
}
|
||||
display, indexable, err := idx.GetDirInfoCore(dir, stat, "/", opts, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDirInfoCore: %v", err)
|
||||
}
|
||||
|
||||
displayNames := make(map[string]bool, len(display.Files))
|
||||
for _, f := range display.Files {
|
||||
displayNames[f.Name] = true
|
||||
}
|
||||
for _, name := range []string{"visible.txt", ".hidden.txt", "viewable.txt"} {
|
||||
if !displayNames[name] {
|
||||
t.Errorf("expected %s in display listing", name)
|
||||
}
|
||||
}
|
||||
|
||||
if indexable == nil {
|
||||
t.Fatal("expected non-nil indexable listing")
|
||||
}
|
||||
if len(indexable.Files) != 1 || indexable.Files[0].Name != "visible.txt" {
|
||||
names := make([]string, 0, len(indexable.Files))
|
||||
for _, f := range indexable.Files {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
t.Fatalf("expected only visible.txt in indexable listing, got %v", names)
|
||||
}
|
||||
if indexable.Size != 4 {
|
||||
t.Errorf("expected indexable size 4 (visible.txt only), got %d", indexable.Size)
|
||||
}
|
||||
|
||||
// Persisting must write only indexable rows and set the folder size.
|
||||
idx.persistDirectoryListing("/", indexable, opts)
|
||||
|
||||
if size, ok := idx.GetFolderSize("/"); !ok || size != 4 {
|
||||
t.Errorf("expected folder size 4, got %d (ok=%v)", size, ok)
|
||||
}
|
||||
meta, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("expected indexed directory metadata after persist")
|
||||
}
|
||||
if len(meta.Files) != 1 || meta.Files[0].Name != "visible.txt" {
|
||||
names := make([]string, 0, len(meta.Files))
|
||||
for _, f := range meta.Files {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
t.Fatalf("expected only visible.txt indexed, got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistDirectoryListing_PropagatesSizeToParents verifies that when a
|
||||
// refreshed listing changes a cached folder size, the delta is propagated to
|
||||
// ancestor folder sizes rather than leaving them stale.
|
||||
func TestPersistDirectoryListing_PropagatesSizeToParents(t *testing.T) {
|
||||
db, _, err := dbsql.NewIndexDB("test_persist_prop", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
idx := &Index{
|
||||
Source: settings.Source{
|
||||
Name: "test_persist_prop",
|
||||
Path: t.TempDir(),
|
||||
Config: settings.SourceConfig{
|
||||
UseLogicalSize: true,
|
||||
},
|
||||
},
|
||||
db: db,
|
||||
scanUpdatedPaths: make(map[string]bool),
|
||||
folderSizes: make(map[string]uint64),
|
||||
folderSizesUnsynced: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
opts := Options{CheckViewable: true}
|
||||
listing := func(size int64) *iteminfo.FileInfo {
|
||||
return &iteminfo.FileInfo{
|
||||
Path: "/sub/",
|
||||
ItemInfo: iteminfo.ItemInfo{Name: "sub", Type: "directory", Size: size},
|
||||
}
|
||||
}
|
||||
|
||||
// Seed stale sizes: child contributes 40 toward the parent's 100.
|
||||
idx.SetFolderSize("/", 100)
|
||||
idx.SetFolderSize("/sub/", 40)
|
||||
|
||||
idx.persistDirectoryListing("/sub/", listing(60), opts)
|
||||
if size, _ := idx.GetFolderSize("/sub/"); size != 60 {
|
||||
t.Errorf("expected /sub/ size 60, got %d", size)
|
||||
}
|
||||
if size, _ := idx.GetFolderSize("/"); size != 120 {
|
||||
t.Errorf("expected / size 120 after +20 delta, got %d", size)
|
||||
}
|
||||
|
||||
// Shrink propagates a negative delta too.
|
||||
idx.persistDirectoryListing("/sub/", listing(30), opts)
|
||||
if size, _ := idx.GetFolderSize("/"); size != 90 {
|
||||
t.Errorf("expected / size 90 after -30 delta, got %d", size)
|
||||
}
|
||||
}
|
||||
|
||||
// mockFileInfo is a simple mock implementation of os.FileInfo for testing
|
||||
type mockFileInfo struct {
|
||||
name string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -123,8 +124,6 @@ type Index struct {
|
||||
schedulerStop chan struct{}
|
||||
schedulerStopOnce sync.Once
|
||||
schedulerBatch int // >0: serial pass in progress; suppresses PostScan inside executeScan
|
||||
// Adaptive scheduler: run one serial scan on process start (then normal slot cadence).
|
||||
schedulerStartupPassPending bool
|
||||
// In-memory folder size tracking (not stored in SQLite)
|
||||
folderSizes map[string]uint64 // path -> size (in-memory only, calculated from children)
|
||||
folderSizesUnsynced map[string]struct{} // Tracks which folder sizes have changed since last DB sync
|
||||
@@ -637,7 +636,29 @@ func (idx *Index) getDirInfoFromContext(ctx *PathContext, isViewable, isIndexabl
|
||||
HideFileExt: req.HideFileExt,
|
||||
}
|
||||
|
||||
return idx.GetDirInfoCore(dir, ctx.FileInfo, ctx.IndexPath, opts, nil)
|
||||
dirFileInfo, indexableInfo, err := idx.GetDirInfoCore(dir, ctx.FileInfo, ctx.IndexPath, opts, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx.persistDirectoryListing(ctx.IndexPath, indexableInfo, opts)
|
||||
return dirFileInfo, nil
|
||||
}
|
||||
|
||||
// persistDirectoryListing updates index rows and the in-memory folder size from
|
||||
// an already-built directory listing, avoiding the second directory walk that
|
||||
// RefreshDirectory performs. Listings without extended attributes carry
|
||||
// placeholder preview values and must not overwrite index rows.
|
||||
func (idx *Index) persistDirectoryListing(indexPath string, indexableInfo *iteminfo.FileInfo, opts Options) {
|
||||
if indexableInfo == nil || opts.SkipExtendedAttrs || opts.IsRoutineScan || idx.db == nil {
|
||||
return
|
||||
}
|
||||
idx.UpdateMetadata(indexableInfo, nil, true)
|
||||
newSize := uint64(indexableInfo.Size)
|
||||
if previousSize, exists := idx.GetFolderSize(indexPath); exists {
|
||||
idx.updateFolderSizeAndParents(indexPath, newSize, previousSize, true)
|
||||
} else {
|
||||
idx.SetFolderSize(indexPath, newSize)
|
||||
}
|
||||
}
|
||||
|
||||
// getBasicDirInfo returns just directory metadata without children
|
||||
@@ -758,7 +779,7 @@ func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileI
|
||||
}
|
||||
|
||||
// Handle directory case
|
||||
response, err := idx.GetDirInfoCore(dir, dirInfo, indexPath, opts, nil)
|
||||
response, _, err := idx.GetDirInfoCore(dir, dirInfo, indexPath, opts, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -806,15 +827,25 @@ func (idx *Index) GetFsInfoViewableOnly(adjustedPath string, followSymlinks bool
|
||||
// fetchExtendedAttributes loads indexed subfolder has_preview for the current directory from GetMetadataInfo (database).
|
||||
// The listed directory's own HasPreview is derived only in GetDirInfoCore from children (never from a stale parent DB row).
|
||||
func (idx *Index) fetchExtendedAttributes(indexPath string, opts Options) map[string]bool {
|
||||
subdirHasPreviewMap, _ := idx.fetchDirectoryCache(indexPath, opts)
|
||||
return subdirHasPreviewMap
|
||||
}
|
||||
|
||||
// fetchDirectoryCache loads the indexed directory row with all direct children in
|
||||
// a single GetMetadataInfo query and returns both the subfolder has_preview map
|
||||
// and a per-name cache of indexed file metadata. The file cache lets
|
||||
// processFileItem reuse type/preview fields for unchanged entries instead of
|
||||
// re-opening every file on each listing.
|
||||
func (idx *Index) fetchDirectoryCache(indexPath string, opts Options) (map[string]bool, map[string]*iteminfo.ItemInfo) {
|
||||
subdirHasPreviewMap := make(map[string]bool)
|
||||
|
||||
if opts.SkipExtendedAttrs || opts.Recursive {
|
||||
return subdirHasPreviewMap
|
||||
return subdirHasPreviewMap, nil
|
||||
}
|
||||
|
||||
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, false)
|
||||
if !exists {
|
||||
return subdirHasPreviewMap
|
||||
return subdirHasPreviewMap, nil
|
||||
}
|
||||
|
||||
for _, folder := range realDirInfo.Folders {
|
||||
@@ -822,7 +853,16 @@ func (idx *Index) fetchExtendedAttributes(indexPath string, opts Options) map[st
|
||||
subdirHasPreviewMap[key] = folder.HasPreview
|
||||
}
|
||||
|
||||
return subdirHasPreviewMap
|
||||
var cachedFiles map[string]*iteminfo.ItemInfo
|
||||
if len(realDirInfo.Files) > 0 {
|
||||
cachedFiles = make(map[string]*iteminfo.ItemInfo, len(realDirInfo.Files))
|
||||
for i := range realDirInfo.Files {
|
||||
file := &realDirInfo.Files[i]
|
||||
cachedFiles[file.Name] = &file.ItemInfo
|
||||
}
|
||||
}
|
||||
|
||||
return subdirHasPreviewMap, cachedFiles
|
||||
}
|
||||
|
||||
// processDirectoryItem processes a directory item and returns the itemInfo, size, and whether it should be counted
|
||||
@@ -878,7 +918,9 @@ func (idx *Index) processDirectoryItem(file os.FileInfo, indexPath string, subdi
|
||||
}
|
||||
|
||||
// processFileItem processes a file item and returns the itemInfo, size, shouldCount, and whether it bubbles up hasPreview
|
||||
func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Options, scanner *Scanner) (*iteminfo.ItemInfo, int64, bool, bool) {
|
||||
// cachedFiles holds indexed metadata for this directory's children (nil when unavailable); unchanged files
|
||||
// reuse their cached type/preview instead of re-opening the file for detection.
|
||||
func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Options, scanner *Scanner, cachedFiles map[string]*iteminfo.ItemInfo) (*iteminfo.ItemInfo, int64, bool, bool) {
|
||||
|
||||
fullCombined := utils.JoinPathAsUnix(idx.Path, indexPath, file.Name())
|
||||
itemInfo := &iteminfo.ItemInfo{
|
||||
@@ -887,7 +929,6 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
|
||||
ModTime: file.ModTime(),
|
||||
Hidden: IsHidden(fullCombined),
|
||||
}
|
||||
itemInfo.DetectType(fullCombined, false)
|
||||
|
||||
// For API calls (non-recursive, no scanner), use appropriate size calculation
|
||||
// For scanning (recursive or with scanner), use handleFile for hardlink detection
|
||||
@@ -901,11 +942,32 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
|
||||
}
|
||||
itemInfo.Size = int64(size)
|
||||
|
||||
// The index row matches only when the file is unchanged (same size and
|
||||
// mtime at index precision) - otherwise detection runs fresh below.
|
||||
var cached *iteminfo.ItemInfo
|
||||
if cachedFiles != nil {
|
||||
if c, ok := cachedFiles[file.Name()]; ok && c.ModTime.Unix() == file.ModTime().Unix() && c.Size == itemInfo.Size {
|
||||
cached = c
|
||||
}
|
||||
}
|
||||
if cached != nil && cached.Type != "" {
|
||||
itemInfo.Type = cached.Type
|
||||
} else {
|
||||
itemInfo.DetectType(fullCombined, false)
|
||||
}
|
||||
if cached != nil && itemInfo.Created == nil && cached.Created != nil {
|
||||
created := *cached.Created
|
||||
itemInfo.Created = &created
|
||||
}
|
||||
|
||||
// Extended attributes for files
|
||||
bubblesUpHasPreview := false
|
||||
if !opts.SkipExtendedAttrs {
|
||||
usedCachedPreview := false
|
||||
if !idx.Config.ResolvedRules.IndexingDisabled && opts.Recursive {
|
||||
if cached != nil {
|
||||
itemInfo.HasPreview = cached.HasPreview
|
||||
usedCachedPreview = true
|
||||
} else if !idx.Config.ResolvedRules.IndexingDisabled && opts.Recursive {
|
||||
simpleType := strings.Split(itemInfo.Type, "/")[0]
|
||||
if simpleType == "audio" {
|
||||
previousInfo, exists := idx.GetReducedMetadata(fullCombined, false)
|
||||
@@ -926,16 +988,19 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
|
||||
return itemInfo, itemInfo.Size, shouldCountSize, bubblesUpHasPreview
|
||||
}
|
||||
|
||||
// GetDirInfoCore is the consolidated implementation for both GetDirInfo and GetDirInfoViewableOnly
|
||||
func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath string, opts Options, scanner *Scanner) (*iteminfo.FileInfo, error) {
|
||||
// GetDirInfoCore is the consolidated implementation for both GetDirInfo and GetDirInfoViewableOnly.
|
||||
// It returns the display listing plus, for non-recursive viewable listings, a second listing holding
|
||||
// only the items indexing would persist (non-hidden, non-skipped) so callers can keep the index in
|
||||
// sync without walking the directory again. The second return is nil when it would match the first.
|
||||
func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath string, opts Options, scanner *Scanner) (*iteminfo.FileInfo, *iteminfo.FileInfo, error) {
|
||||
if !strings.HasSuffix(indexPath, "/") {
|
||||
indexPath = indexPath + "/"
|
||||
}
|
||||
files, err := dirInfo.Readdir(-1)
|
||||
files, err := readDirFileInfos(dirInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, opts)
|
||||
subdirHasPreviewMap, cachedFiles := idx.fetchDirectoryCache(indexPath, opts)
|
||||
|
||||
baseName := filepath.Base(indexPath)
|
||||
if indexPath == "/" {
|
||||
@@ -943,10 +1008,17 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
}
|
||||
|
||||
var totalSize int64
|
||||
var indexableSize int64
|
||||
fileInfos := []iteminfo.ExtendedItemInfo{}
|
||||
dirInfos := []iteminfo.ItemInfo{}
|
||||
// Items indexing would persist: API listings may include viewable-only or
|
||||
// hidden entries that RefreshDirectory (ShowHidden=false) never indexes.
|
||||
collectIndexable := opts.CheckViewable && !opts.Recursive && scanner == nil && !opts.SkipExtendedAttrs
|
||||
var indexableFiles []iteminfo.ExtendedItemInfo
|
||||
var indexableFolders []iteminfo.ItemInfo
|
||||
processedCount := 0
|
||||
hasPreview := false
|
||||
indexableHasPreview := false
|
||||
|
||||
for _, file := range files {
|
||||
subfileBaseName := file.Name()
|
||||
@@ -973,6 +1045,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
}
|
||||
|
||||
// Check viewable/indexable rules
|
||||
itemIndexable := false
|
||||
if !shouldSkip {
|
||||
isViewable := idx.IsViewable(isDir, fullCombined, isSymlink, hidden)
|
||||
isSkipped := idx.ShouldSkip(isDir, fullCombined, hidden, isSymlink, opts.IsRoutineScan)
|
||||
@@ -981,6 +1054,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
} else {
|
||||
shouldSkip = isSkipped
|
||||
}
|
||||
itemIndexable = !hidden && !isSkipped
|
||||
}
|
||||
if shouldSkip {
|
||||
continue
|
||||
@@ -994,20 +1068,36 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
}
|
||||
if shouldCount {
|
||||
totalSize += size
|
||||
if itemIndexable {
|
||||
indexableSize += size
|
||||
}
|
||||
}
|
||||
dirInfos = append(dirInfos, *itemInfo)
|
||||
if collectIndexable && itemIndexable {
|
||||
indexableFolders = append(indexableFolders, *itemInfo)
|
||||
}
|
||||
if opts.Recursive && opts.IsRoutineScan {
|
||||
idx.incrementScannerDirs()
|
||||
}
|
||||
} else {
|
||||
itemInfo, size, shouldCount, bubblesUp := idx.processFileItem(file, indexPath, opts, scanner)
|
||||
itemInfo, size, shouldCount, bubblesUp := idx.processFileItem(file, indexPath, opts, scanner, cachedFiles)
|
||||
if bubblesUp {
|
||||
hasPreview = true
|
||||
if itemIndexable {
|
||||
indexableHasPreview = true
|
||||
}
|
||||
}
|
||||
if shouldCount {
|
||||
totalSize += size
|
||||
if itemIndexable {
|
||||
indexableSize += size
|
||||
}
|
||||
}
|
||||
extItem := iteminfo.ExtendedItemInfo{ItemInfo: *itemInfo}
|
||||
fileInfos = append(fileInfos, extItem)
|
||||
if collectIndexable && itemIndexable {
|
||||
indexableFiles = append(indexableFiles, extItem)
|
||||
}
|
||||
fileInfos = append(fileInfos, iteminfo.ExtendedItemInfo{ItemInfo: *itemInfo})
|
||||
if opts.IsRoutineScan {
|
||||
idx.incrementScannerFiles()
|
||||
}
|
||||
@@ -1031,7 +1121,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
}
|
||||
|
||||
if isEmpty {
|
||||
return nil, errors.ErrNotIndexed
|
||||
return nil, nil, errors.ErrNotIndexed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1050,16 +1140,39 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
}
|
||||
dirFileInfo.SortItems()
|
||||
|
||||
return dirFileInfo, nil
|
||||
var indexableInfo *iteminfo.FileInfo
|
||||
if collectIndexable {
|
||||
indexableInfo = &iteminfo.FileInfo{
|
||||
Path: indexPath,
|
||||
Files: indexableFiles,
|
||||
Folders: indexableFolders,
|
||||
}
|
||||
indexableInfo.ItemInfo = iteminfo.ItemInfo{
|
||||
Name: baseName,
|
||||
Type: "directory",
|
||||
Size: indexableSize,
|
||||
Created: dirFileInfo.Created,
|
||||
ModTime: dirFileInfo.ModTime,
|
||||
HasPreview: indexableHasPreview,
|
||||
}
|
||||
}
|
||||
|
||||
return dirFileInfo, indexableInfo, nil
|
||||
}
|
||||
|
||||
// GetDirInfo returns directory information with index checks and extended attributes
|
||||
func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, indexPath string, opts Options, scanner *Scanner) (*iteminfo.FileInfo, error) {
|
||||
// Ensure filesystem options are set correctly for indexed paths
|
||||
opts.SkipExtendedAttrs = false
|
||||
return idx.GetDirInfoCore(dirInfo, stat, indexPath, opts, scanner)
|
||||
dirFileInfo, _, err := idx.GetDirInfoCore(dirInfo, stat, indexPath, opts, scanner)
|
||||
return dirFileInfo, err
|
||||
}
|
||||
|
||||
// GetRealPath resolves an index path to a real filesystem path, enforcing
|
||||
// source-root containment. It is cached (RealPathCache) — use it for reads and
|
||||
// bookkeeping only. Mutating filesystem operations must use
|
||||
// GetRealPathForMutation or GetRealPathForRemoval, which resolve uncached and
|
||||
// enforce a caller-supplied scope bound.
|
||||
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
|
||||
return idx.getRealPathInternal("/", false, relativePath...)
|
||||
}
|
||||
@@ -1106,6 +1219,86 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
|
||||
return realPath, isDir, nil
|
||||
}
|
||||
|
||||
// GetRealPathForMutation resolves an index path for a mutating filesystem
|
||||
// operation (create/overwrite/mkdir/move-into). When the leaf exists it is fully
|
||||
// resolved; otherwise the deepest existing ancestor is resolved and the missing
|
||||
// tail is re-joined, since non-existent components cannot be symlinks. The
|
||||
// resolved path is verified to stay inside the source root and boundIndexPath
|
||||
// ("" or "/" = source root only). Resolution is uncached so a returned path is
|
||||
// safe to mutate immediately.
|
||||
func (idx *Index) GetRealPathForMutation(boundIndexPath, indexPath string) (string, error) {
|
||||
return idx.resolveForMutation(boundIndexPath, indexPath, true)
|
||||
}
|
||||
|
||||
// GetRealPathForRemoval resolves an index path for entry-removal semantics
|
||||
// (delete/rename-of-entry): the parent chain is resolved and contained, but the
|
||||
// leaf name is preserved so that removing a symlink removes the link itself
|
||||
// rather than its target.
|
||||
func (idx *Index) GetRealPathForRemoval(boundIndexPath, indexPath string) (string, error) {
|
||||
return idx.resolveForMutation(boundIndexPath, indexPath, false)
|
||||
}
|
||||
|
||||
func (idx *Index) resolveForMutation(boundIndexPath, indexPath string, resolveLeaf bool) (string, error) {
|
||||
abs, err := filepath.Abs(utils.JoinUnderSourceRoot(idx.Path, indexPath))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get real path: %v, %s", indexPath, err)
|
||||
}
|
||||
// The source root has no leaf to preserve for entry-removal semantics;
|
||||
// resolve it so callers can apply their own root handling.
|
||||
if !resolveLeaf && abs == filepath.Clean(idx.Path) {
|
||||
resolved, _, rerr := iteminfo.ResolveSymlinks(abs)
|
||||
if rerr != nil {
|
||||
return "", rerr
|
||||
}
|
||||
if cerr := idx.ensureResolvedContained(resolved, boundIndexPath); cerr != nil {
|
||||
return "", cerr
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
if resolveLeaf {
|
||||
resolved, _, rerr := iteminfo.ResolveSymlinks(abs)
|
||||
if rerr == nil {
|
||||
if cerr := idx.ensureResolvedContained(resolved, boundIndexPath); cerr != nil {
|
||||
return "", cerr
|
||||
}
|
||||
return resolved, nil
|
||||
} else if !stderrors.Is(rerr, os.ErrNotExist) {
|
||||
return "", rerr
|
||||
}
|
||||
li, lerr := os.Lstat(abs)
|
||||
if lerr == nil && li.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("%w: dangling symlink leaf", errors.ErrPathEscapesScope)
|
||||
} else if lerr != nil && !stderrors.Is(lerr, os.ErrNotExist) {
|
||||
return "", lerr
|
||||
}
|
||||
}
|
||||
// Walk up to the deepest existing ancestor. Missing tail components cannot
|
||||
// be symlinks since they do not exist, so re-joining them is safe.
|
||||
tail := []string{filepath.Base(abs)}
|
||||
ancestor := filepath.Dir(abs)
|
||||
for {
|
||||
resolved, isDir, aerr := iteminfo.ResolveSymlinks(ancestor)
|
||||
if aerr == nil {
|
||||
if !isDir {
|
||||
return "", fmt.Errorf("parent path is not a directory")
|
||||
}
|
||||
if cerr := idx.ensureResolvedContained(resolved, boundIndexPath); cerr != nil {
|
||||
return "", cerr
|
||||
}
|
||||
return filepath.Join(append([]string{resolved}, tail...)...), nil
|
||||
}
|
||||
if !stderrors.Is(aerr, os.ErrNotExist) {
|
||||
return "", aerr
|
||||
}
|
||||
next := filepath.Dir(ancestor)
|
||||
if next == ancestor {
|
||||
return "", fmt.Errorf("no existing ancestor for path %s", indexPath)
|
||||
}
|
||||
tail = append([]string{filepath.Base(ancestor)}, tail...)
|
||||
ancestor = next
|
||||
}
|
||||
}
|
||||
|
||||
func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
|
||||
// For files, refresh the parent directory
|
||||
if !opts.IsDir {
|
||||
@@ -1675,6 +1868,7 @@ func (idx *Index) writePersistedIndexInfo() error {
|
||||
NumDirs: scanner.numDirs,
|
||||
NumFiles: scanner.numFiles,
|
||||
LastScanned: scanner.lastScanned,
|
||||
FullScanCounter: scanner.fullScanCounter,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,23 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreFolderSizes loads the persisted directory sizes into the in-memory folderSizes map.
|
||||
// Quick scans iterate that map to decide which folders to stat, so leaving it empty after a
|
||||
// restart would make them blind until the next full scan.
|
||||
func (idx *Index) restoreFolderSizes() {
|
||||
if idx.db == nil {
|
||||
return
|
||||
}
|
||||
sizes, err := idx.db.LoadFolderSizes(idx.Name)
|
||||
if err != nil {
|
||||
logger.Errorf("[%s] Failed to load persisted folder sizes: %v", idx.Name, err)
|
||||
return
|
||||
}
|
||||
idx.folderSizesMu.Lock()
|
||||
idx.folderSizes = sizes
|
||||
idx.folderSizesMu.Unlock()
|
||||
}
|
||||
|
||||
// setupMultiScanner creates and starts the multi-scanner system
|
||||
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
|
||||
func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
@@ -250,6 +267,11 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
if err := idx.Load(); err != nil {
|
||||
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
|
||||
}
|
||||
// Quick scans only stat the directories present in folderSizes, which is in-memory only.
|
||||
// Restoring it keeps change detection working after a restart without a full re-read.
|
||||
if !isNewDb {
|
||||
idx.restoreFolderSizes()
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
idx.scanners = make(map[string]*Scanner)
|
||||
@@ -318,12 +340,6 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
|
||||
if idx.useAdaptiveScheduling() {
|
||||
idx.mu.Lock()
|
||||
idx.schedulerStartupPassPending = true
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
|
||||
idx.restoreScannerNextRuns()
|
||||
go idx.runIndexScheduler()
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
indexingdb "github.com/gtsteffaniak/filebrowser/backend/internal/database/dbindex"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
|
||||
ierrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
// fakeMetaStore is a minimal ports.IndexMetaStore for startup/restore tests.
|
||||
type fakeMetaStore struct {
|
||||
info *indexingdb.IndexInfo
|
||||
saved *indexingdb.IndexInfo
|
||||
}
|
||||
|
||||
func (f *fakeMetaStore) GetIndexInfo(path string) (indexingdb.IndexInfo, error) {
|
||||
if f.info == nil {
|
||||
return indexingdb.IndexInfo{}, ierrors.ErrNotExist
|
||||
}
|
||||
return *f.info, nil
|
||||
}
|
||||
|
||||
func (f *fakeMetaStore) SaveIndexInfo(info *indexingdb.IndexInfo) error {
|
||||
f.saved = info
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMetaStore) ResetAllIndexComplexities() error { return nil }
|
||||
|
||||
// persistedScanners builds persisted scanner info that is one hour old on the slowest tier, so the
|
||||
// restored nextRun lies in the future and no scan is due while a test runs.
|
||||
func persistedScanners(paths ...string) map[string]*indexingdb.PersistedScannerInfo {
|
||||
out := make(map[string]*indexingdb.PersistedScannerInfo, len(paths))
|
||||
last := time.Now().Add(-time.Hour)
|
||||
for _, p := range paths {
|
||||
out[p] = &indexingdb.PersistedScannerInfo{
|
||||
Path: p,
|
||||
CurrentSchedule: len(scanScheduleTiers) - 1,
|
||||
LastScanned: last,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newRestoreTestIndex returns an index over a temp source directory and a temp index database.
|
||||
// persisted is served by the meta store unless it is nil (no persisted index data).
|
||||
func newRestoreTestIndex(t *testing.T, name string, subdirs []string, persisted map[string]*indexingdb.PersistedScannerInfo) *Index {
|
||||
t.Helper()
|
||||
srcDir := t.TempDir()
|
||||
for _, dir := range subdirs {
|
||||
if err := os.MkdirAll(filepath.Join(srcDir, dir), 0o755); err != nil {
|
||||
t.Fatalf("creating source subdir: %v", err)
|
||||
}
|
||||
}
|
||||
db, _, err := dbsql.NewIndexDB("test_"+name, "OFF", 1000, 32, true)
|
||||
if err != nil {
|
||||
t.Fatalf("creating test index db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
store := &fakeMetaStore{}
|
||||
if persisted != nil {
|
||||
store.info = &indexingdb.IndexInfo{Path: srcDir, Source: name, Scanners: persisted}
|
||||
}
|
||||
SetMetaStore(store)
|
||||
t.Cleanup(func() { SetMetaStore(nil) })
|
||||
|
||||
idx := &Index{
|
||||
Source: settings.Source{Name: name, Path: srcDir},
|
||||
db: db,
|
||||
mock: true,
|
||||
folderSizes: make(map[string]uint64),
|
||||
folderSizesUnsynced: make(map[string]struct{}),
|
||||
scanUpdatedPaths: make(map[string]bool),
|
||||
}
|
||||
// Registered last so the scheduler goroutine stops before the database is closed.
|
||||
t.Cleanup(idx.stopScheduler)
|
||||
return idx
|
||||
}
|
||||
|
||||
// A restart over a persisted index resumes the stored schedule instead of serial-scanning every
|
||||
// scanner, which is what re-reads every already-indexed file on every start.
|
||||
func TestRestartWithPersistedScannersSkipsSerialPass(t *testing.T) {
|
||||
idx := newRestoreTestIndex(t, "restart_no_pass", nil, persistedScanners("/"))
|
||||
idx.setupMultiScanner(false)
|
||||
if idx.needsAdaptiveBootstrap() {
|
||||
t.Fatalf("persisted scanners must resume their schedule, not force a serial startup pass")
|
||||
}
|
||||
}
|
||||
|
||||
// folderSizes is in-memory only and quick scans pick the folders to stat from it, so a restart must
|
||||
// restore it from the persisted index or change detection goes blind until the next full scan.
|
||||
func TestFolderSizesRestoredOnStartup(t *testing.T) {
|
||||
idx := newRestoreTestIndex(t, "restore_sizes", []string{"sub"}, persistedScanners("/", "/sub/"))
|
||||
idx.UpdateMetadata(&iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{Type: "directory"},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "sub", Type: "directory", Size: 4242},
|
||||
},
|
||||
}, nil, true)
|
||||
|
||||
idx.folderSizesMu.Lock()
|
||||
idx.folderSizes = make(map[string]uint64)
|
||||
idx.folderSizesUnsynced = make(map[string]struct{})
|
||||
idx.folderSizesMu.Unlock()
|
||||
|
||||
idx.setupMultiScanner(false)
|
||||
|
||||
size, ok := idx.GetFolderSize("/sub/")
|
||||
if !ok {
|
||||
t.Fatalf("persisted folder size for /sub/ was not restored")
|
||||
}
|
||||
if size != 4242 {
|
||||
t.Fatalf("restored size for /sub/ = %d, want 4242", size)
|
||||
}
|
||||
}
|
||||
|
||||
// fullScanCounter decides quick vs full for the next run; dropping it on restart means every
|
||||
// scanner does a full scan (and DetectType opens every file) after every restart.
|
||||
func TestFullScanCounterSurvivesRestart(t *testing.T) {
|
||||
first := newRestoreTestIndex(t, "counter_write", nil, nil)
|
||||
first.scanners = map[string]*Scanner{
|
||||
"/": {
|
||||
scanPath: "/",
|
||||
idx: first,
|
||||
fullScanCounter: 3,
|
||||
currentSchedule: len(scanScheduleTiers) - 1,
|
||||
lastScanned: time.Now().Add(-time.Hour),
|
||||
},
|
||||
}
|
||||
if err := first.writePersistedIndexInfo(); err != nil {
|
||||
t.Fatalf("writePersistedIndexInfo: %v", err)
|
||||
}
|
||||
store, ok := metaStore.(*fakeMetaStore)
|
||||
if !ok || store.saved == nil || store.saved.Scanners["/"] == nil {
|
||||
t.Fatal("scanner info was not persisted")
|
||||
}
|
||||
if got := store.saved.Scanners["/"].FullScanCounter; got != 3 {
|
||||
t.Fatalf("persisted fullScanCounter = %d, want 3", got)
|
||||
}
|
||||
|
||||
second := newRestoreTestIndex(t, "counter_read", nil, store.saved.Scanners)
|
||||
second.setupMultiScanner(false)
|
||||
second.mu.RLock()
|
||||
scanner := second.scanners["/"]
|
||||
second.mu.RUnlock()
|
||||
if scanner == nil {
|
||||
t.Fatal("root scanner missing after restart")
|
||||
}
|
||||
var restored int
|
||||
scanner.withStatsRLock(func() { restored = scanner.fullScanCounter })
|
||||
if restored != 3 {
|
||||
t.Fatalf("restored fullScanCounter = %d, want 3", restored)
|
||||
}
|
||||
}
|
||||
|
||||
// An index with no persisted scan times must still do the serial pass, or a new or empty index
|
||||
// would never be scanned.
|
||||
func TestEmptyIndexStillBootstrapsSerialPass(t *testing.T) {
|
||||
idx := &Index{
|
||||
Source: settings.Source{Name: "empty", Path: t.TempDir()},
|
||||
mock: true,
|
||||
scanners: map[string]*Scanner{
|
||||
"/": {scanPath: "/"},
|
||||
},
|
||||
}
|
||||
if !idx.needsAdaptiveBootstrap() {
|
||||
t.Fatal("a scanner with no lastScanned must trigger the serial pass")
|
||||
}
|
||||
idx.scanners["/"].lastScanned = time.Now()
|
||||
if idx.needsAdaptiveBootstrap() {
|
||||
t.Fatal("a scanner with a restored lastScanned must not trigger the serial pass")
|
||||
}
|
||||
}
|
||||
@@ -197,9 +197,6 @@ func (idx *Index) pruneGoneChildScanners() {
|
||||
func (idx *Index) needsAdaptiveBootstrap() bool {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
if idx.schedulerStartupPassPending {
|
||||
return true
|
||||
}
|
||||
for _, s := range idx.scanners {
|
||||
if s.lastScanned.IsZero() {
|
||||
return true
|
||||
@@ -241,20 +238,8 @@ func (idx *Index) runSerialScanPass() {
|
||||
func (idx *Index) schedulerAdaptiveTick() {
|
||||
idx.pruneGoneChildScanners()
|
||||
if idx.needsAdaptiveBootstrap() {
|
||||
idx.mu.Lock()
|
||||
startupPass := idx.schedulerStartupPassPending
|
||||
idx.mu.Unlock()
|
||||
if startupPass {
|
||||
logger.Debugf("[%s] scheduler: adaptive startup (serial pass all scanners)", idx.Name)
|
||||
} else {
|
||||
logger.Debugf("[%s] scheduler: adaptive tick bootstrap (serial pass all scanners)", idx.Name)
|
||||
}
|
||||
logger.Debugf("[%s] scheduler: adaptive bootstrap (serial pass all scanners)", idx.Name)
|
||||
idx.runSerialScanPass()
|
||||
if startupPass {
|
||||
idx.mu.Lock()
|
||||
idx.schedulerStartupPassPending = false
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
return
|
||||
}
|
||||
drainedAny := false
|
||||
|
||||
@@ -9,14 +9,16 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testutil.QuietLogging()
|
||||
// Ensure fileutils permissions are set (needed by NewTempDB)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
|
||||
@@ -163,6 +163,150 @@ func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForMutation_ExistingInScope(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
realPath, err := idx.GetRealPathForMutation(userScope, "/user1/allowed.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected in-scope path to resolve: %v", err)
|
||||
}
|
||||
want, err := filepath.EvalSymlinks(filepath.Join(sourceRoot, "user1", "allowed.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if realPath != want {
|
||||
t.Fatalf("got %q, want %q", realPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForMutation_RejectsEscapingLeaf(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
if _, err := idx.GetRealPathForMutation(userScope, "/user1/secret_link"); !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForMutation_AllowsMissingLeafInScope(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
realPath, err := idx.GetRealPathForMutation(userScope, "/user1/newfile.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("expected missing leaf under existing dir to resolve: %v", err)
|
||||
}
|
||||
want, err := filepath.EvalSymlinks(filepath.Join(sourceRoot, "user1", "newfile.txt"))
|
||||
if err != nil {
|
||||
// Leaf does not exist; resolve the parent instead.
|
||||
parent, perr := filepath.EvalSymlinks(filepath.Join(sourceRoot, "user1"))
|
||||
if perr != nil {
|
||||
t.Fatal(perr)
|
||||
}
|
||||
want = filepath.Join(parent, "newfile.txt")
|
||||
}
|
||||
if realPath != want {
|
||||
t.Fatalf("got %q, want %q", realPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForMutation_RejectsDanglingSymlinkLeaf(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
outside := filepath.Join(filepath.Dir(sourceRoot), "missing-outside.txt")
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.Symlink(outside, filepath.Join(userDir, "dangling")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := idx.GetRealPathForMutation(userScope, "/user1/dangling"); !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope for dangling symlink leaf, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForMutation_RejectsMissingLeafUnderEscapingParent(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
outsideDir := filepath.Join(filepath.Dir(sourceRoot), "outside-dir")
|
||||
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideDir, filepath.Join(userDir, "evil_dir")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := idx.GetRealPathForMutation(userScope, "/user1/evil_dir/hack.txt"); !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope for missing leaf under escaping parent, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForRemoval_PreservesSymlinkLeaf(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
// Removal semantics must return the link itself (parent resolved, leaf not
|
||||
// followed) so delete removes the link rather than its target.
|
||||
realPath, err := idx.GetRealPathForRemoval(userScope, "/user1/secret_link")
|
||||
if err != nil {
|
||||
t.Fatalf("expected link path to resolve for removal: %v", err)
|
||||
}
|
||||
parent, err := filepath.EvalSymlinks(filepath.Join(sourceRoot, "user1"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(parent, "secret_link")
|
||||
if realPath != want {
|
||||
t.Fatalf("got %q, want link itself %q", realPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForRemoval_RejectsEscapingIntermediate(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
outsideDir := filepath.Join(filepath.Dir(sourceRoot), "outside-dir2")
|
||||
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideDir, filepath.Join(userDir, "evil_dir")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := idx.GetRealPathForRemoval(userScope, "/user1/evil_dir/victim.txt"); !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope for removal under escaping parent, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathForMutation_EnforcesBoundInsideSource(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
// Link inside the user scope pointing elsewhere inside the source is allowed
|
||||
// for the source bound but denied for the narrower user-scope bound.
|
||||
insideSource := filepath.Join(sourceRoot, "other", "file.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(insideSource), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(insideSource, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.Symlink(insideSource, filepath.Join(userDir, "cross_link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := idx.GetRealPathForMutation("/", "/user1/cross_link"); err != nil {
|
||||
t.Fatalf("source-bound resolution should allow in-source target: %v", err)
|
||||
}
|
||||
if _, err := idx.GetRealPathForMutation(userScope, "/user1/cross_link"); !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope under user bound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileInfo_RejectsSymlinkEscapeWithBound(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
@@ -24,10 +24,21 @@ func getFileSizeByMode(stat *syscall.Stat_t, useLogicalSize bool) uint64 {
|
||||
return uint64(stat.Blocks * 512)
|
||||
}
|
||||
|
||||
// unixFileDetailsProvider lets platform-specific os.FileInfo implementations
|
||||
// (e.g. Linux statx/fstatat directory listings) expose size, link count and
|
||||
// inode without an extra path-based stat.
|
||||
type unixFileDetailsProvider interface {
|
||||
unixFileDetails(useLogicalSize bool) (realSize uint64, nlink uint64, ino uint64)
|
||||
}
|
||||
|
||||
func getFileDetails(sys any, filePath string, useLogicalSize bool) (uint64, uint64, uint64, bool) {
|
||||
// If useLogicalSize is true, we still need inode info for hardlink detection
|
||||
// but use logical size instead of allocated size
|
||||
if sys != nil {
|
||||
if provider, ok := sys.(unixFileDetailsProvider); ok {
|
||||
realSize, nlink, ino := provider.unixFileDetails(useLogicalSize)
|
||||
return realSize, nlink, ino, true
|
||||
}
|
||||
if stat, ok := sys.(*syscall.Stat_t); ok {
|
||||
realSize := getFileSizeByMode(stat, useLogicalSize)
|
||||
return realSize, uint64(stat.Nlink), stat.Ino, true
|
||||
|
||||
@@ -382,6 +382,7 @@ func isGoTest() bool {
|
||||
return flag.Lookup("test.v") != nil
|
||||
}
|
||||
|
||||
|
||||
func setupFFmpegIntegration() {
|
||||
n := Config.Server.NumImageProcessors
|
||||
if n < 1 {
|
||||
@@ -600,6 +601,10 @@ func setupLogging() {
|
||||
},
|
||||
}
|
||||
}
|
||||
// Tests quiet the logger in testutil.Main; skip stacking handlers here.
|
||||
if isGoTest() && os.Getenv("FILEBROWSER_TEST_LOG") == "" {
|
||||
return
|
||||
}
|
||||
for i := range Config.Server.Logging {
|
||||
cfg := &Config.Server.Logging[i]
|
||||
// Enable debug logging automatically in dev mode
|
||||
@@ -689,9 +694,11 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
}
|
||||
|
||||
// check if database is set in the config
|
||||
val, ok := filteredConfig["server"].(map[string]interface{})["database"].(string)
|
||||
if ok && val != "" {
|
||||
return fmt.Errorf("old database path is set in the config, please migrate your database to SQLite")
|
||||
if server, ok := filteredConfig["server"].(map[string]interface{}); ok {
|
||||
val, ok := server["database"].(string)
|
||||
if ok && val != "" {
|
||||
return fmt.Errorf("old database path is set in the config, please migrate your database to SQLite")
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal the filtered config back to YAML
|
||||
|
||||
@@ -179,3 +179,39 @@ func TestSearchResultsLimitConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadWithoutServerMap(t *testing.T) {
|
||||
original := Config
|
||||
t.Cleanup(func() { Config = original })
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content string
|
||||
loadErr bool
|
||||
}{
|
||||
{name: "empty file", content: ""},
|
||||
{name: "no server key", content: "frontend:\n name: test\n"},
|
||||
{name: "null server", content: "server:\n"},
|
||||
{name: "scalar server", content: "server: test\n", loadErr: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
configFile := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(configFile, []byte(tc.content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := loadConfigWithDefaults(configFile, true)
|
||||
if tc.loadErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a non-map server section")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("error loading config file: %v", err)
|
||||
}
|
||||
// no sources are configured, so validation should report it
|
||||
if err := ValidateConfig(Config); err == nil {
|
||||
t.Fatal("expected validation error for config with missing required sources, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,8 @@ func TestYAMLConfig_applyUserDefaultsFrom_proxyAndJwt(t *testing.T) {
|
||||
if !u.Permissions.Share {
|
||||
t.Fatal("Share=false after ApplyUserDefaultsFrom")
|
||||
}
|
||||
if len(u.BackendScopes) != 1 || u.BackendScopes[0].Scope != "/demo-127.0.0.1" {
|
||||
t.Fatalf("createUserDir scope: %+v", u.BackendScopes)
|
||||
if len(u.BackendScopes) != 1 || u.BackendScopes[0].Scope != "/" {
|
||||
t.Fatalf("scope after applying defaults: %+v", u.BackendScopes)
|
||||
}
|
||||
perms := u.BackendScopes[0].Permissions
|
||||
if !perms.View || !perms.Modify || !perms.Create || !perms.Download {
|
||||
@@ -115,7 +115,7 @@ func TestYAMLConfig_applyUserDefaultsFrom_proxyAndJwt(t *testing.T) {
|
||||
if u.SingleClick {
|
||||
t.Fatal("expected listing.singleClick false on user")
|
||||
}
|
||||
if u.BackendScopes[0].Scope != "/testuser" {
|
||||
if u.BackendScopes[0].Scope != "/" {
|
||||
t.Fatalf("scope=%q", u.BackendScopes[0].Scope)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -982,7 +982,9 @@ func GenerateYaml() {
|
||||
fmt.Fprintf(os.Stderr, "error writing YAML: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Generated YAML with comments (deprecated fields filtered): %s\n", output)
|
||||
if !isGoTest() {
|
||||
fmt.Printf("Generated YAML with comments (deprecated fields filtered): %s\n", output)
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateConfigYaml generates YAML from a given config with options for comments and filtering
|
||||
|
||||
@@ -444,7 +444,6 @@ func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Generated YAML for %s:\n%s", tt.name, yamlOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,17 +150,35 @@ func ApplyUserDefaults(u *users.User) {
|
||||
// Keep this in sync with [UserDefaults]: every user-facing field there should be copied
|
||||
// here (except DefaultScopes, which is wired through source config rather than this helper).
|
||||
func ApplyUserDefaultsFrom(u *users.User, d UserDefaults) {
|
||||
applyUserDefaultsFrom(u, d, false)
|
||||
}
|
||||
|
||||
// ApplyUserDefaultsFromKeepingAccountPermissions applies profile and scope defaults but
|
||||
// leaves u.Permissions unchanged so default-enabled scope permissions use the caller's
|
||||
// final admin flag (see CreateUser).
|
||||
func ApplyUserDefaultsFromKeepingAccountPermissions(u *users.User, d UserDefaults) {
|
||||
applyUserDefaultsFrom(u, d, true)
|
||||
}
|
||||
|
||||
func applyUserDefaultsFrom(u *users.User, d UserDefaults, keepAccountPermissions bool) {
|
||||
savedPermissions := u.Permissions
|
||||
ApplyFullProfileFromDefaults(u, d)
|
||||
|
||||
if u.Username == "anonymous" {
|
||||
return
|
||||
}
|
||||
|
||||
// Global permissions (admin, api, share, realtime)
|
||||
u.Permissions.Api = d.Account.Permissions.Api
|
||||
u.Permissions.Admin = d.Account.Permissions.Admin
|
||||
u.Permissions.Share = d.Account.Permissions.Share
|
||||
u.Permissions.Realtime = d.Account.Permissions.Realtime
|
||||
// Global permissions (admin, api, share, realtime). Profile merge overwrites these;
|
||||
// restore the caller's values when CreateUser already applied requested permissions
|
||||
// so default-enabled scope permissions use the final admin flag.
|
||||
if keepAccountPermissions {
|
||||
u.Permissions = savedPermissions
|
||||
} else {
|
||||
u.Permissions.Api = d.Account.Permissions.Api
|
||||
u.Permissions.Admin = d.Account.Permissions.Admin
|
||||
u.Permissions.Share = d.Account.Permissions.Share
|
||||
u.Permissions.Realtime = d.Account.Permissions.Realtime
|
||||
}
|
||||
|
||||
sourceDefaults := DefaultSourceFilePermissions()
|
||||
|
||||
@@ -191,8 +209,6 @@ func ApplyUserDefaultsFrom(u *users.User, d UserDefaults) {
|
||||
}
|
||||
}
|
||||
|
||||
ExpandBackendScopesForCreateUserDir(u)
|
||||
|
||||
if u.LoginMethod == "" && d.Account.LoginMethod != "" {
|
||||
u.LoginMethod = users.LoginMethod(d.Account.LoginMethod)
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ func ExpandBackendScopesForCreateUserDir(u *users.User) {
|
||||
if !source.Config.CreateUserDir {
|
||||
continue
|
||||
}
|
||||
// This runs more than once for a new user, so a scope that already ends in the user's
|
||||
// directory is left alone. The source's defaultUserScope itself is not a user's home:
|
||||
// Keep expansion idempotent so a scope that already ends in the user's directory is left
|
||||
// alone. The source's defaultUserScope itself is not a user's home:
|
||||
// a user named after its last element ("users" with "/users") gets "/users/users".
|
||||
if scopeBaseName(scope.Scope) == cleanedUserName && !sameScopePath(scope.Scope, source.Config.DefaultUserScope) {
|
||||
continue
|
||||
|
||||
Generated
+1028
-652
File diff suppressed because it is too large.
Load diff
+21
-19
@@ -31,26 +31,28 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@likecoin/epub-ts": "^0.7.2",
|
||||
"@onlyoffice/document-editor-vue": "^1.6.1",
|
||||
"ace-builds": "^1.44.0",
|
||||
"@onlyoffice/document-editor-vue": "^1.7.0",
|
||||
"ace-builds": "^1.45.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.16",
|
||||
"dompurify": "^3.4.17",
|
||||
"highlight.js": "^11.12.0",
|
||||
"js-yaml": "^5.4.3",
|
||||
"katex": "^0.17.0",
|
||||
"js-yaml": "^5.5.0",
|
||||
"mammoth": "^1.13.0",
|
||||
"marked": "^18.0.1",
|
||||
"marked-katex-extension": "5.1.13",
|
||||
"marked": "^18.1.1",
|
||||
"plyr": "^3.8.5",
|
||||
"qrcode.vue": "^3.11.0",
|
||||
"temml": "^0.14.0",
|
||||
"three": "^0.186.1",
|
||||
"vue": "^3.5.43",
|
||||
"vue-i18n": "^11.4.13",
|
||||
"vue": "^3.5.44",
|
||||
"vue-i18n": "^11.4.16",
|
||||
"vue-router": "^5.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.6",
|
||||
"@xmldom/xmldom": "^0.8.15",
|
||||
"mammoth": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"@intlify/vue-i18n-extensions": {
|
||||
"vue-i18n": "$vue-i18n"
|
||||
},
|
||||
@@ -61,26 +63,26 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^4.5.1",
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.5",
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.7",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@types/node": "^26.6.4",
|
||||
"@typescript-eslint/parser": "^8.71.0",
|
||||
"@vitejs/plugin-vue": "^6.0.9",
|
||||
"deepl-node": "^1.28.0",
|
||||
"eslint": "^10.12.0",
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"@vitejs/plugin-vue": "^6.0.10",
|
||||
"deepl-node": "^1.28.1",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-vue": "^10.11.1",
|
||||
"fs-extra": "^11.4.1",
|
||||
"jsdom": "^30.1.2",
|
||||
"jsdom": "^30.1.3",
|
||||
"stylelint": "^17.16.0",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"stylelint-config-standard-vue": "^2.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.2",
|
||||
"vite": "^8.3.4",
|
||||
"vite-plugin-checker": "^0.14.5",
|
||||
"vite-plugin-compression2": "^2.5.3",
|
||||
"vitest": "^5.0.3",
|
||||
"vitest": "^5.0.5",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.12",
|
||||
"vue-tsc": "^3.3.13",
|
||||
"wawoff2": "^2.0.1"
|
||||
}
|
||||
}
|
||||
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user