mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
101
Commits
v2.1.2-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8415d5f1ff | ||
|
|
85f934208c | ||
|
|
1022ecafef | ||
|
|
062ac9fb2d | ||
|
|
cecdd79fcc | ||
|
|
ac50ecfd18 | ||
|
|
71cc2f3f1b | ||
|
|
1b3f47f176 | ||
|
|
219b64b5ec | ||
|
|
3ef4928ed7 | ||
|
|
df0a5ad10c | ||
|
|
a43d0ab2d3 | ||
|
|
9de09e5ec8 | ||
|
|
9d38c686a1 | ||
|
|
1cfca112e4 | ||
|
|
f540e22dc9 | ||
|
|
dade856ebe | ||
|
|
2f1da21253 | ||
|
|
de8d2acd43 | ||
|
|
6375be202e | ||
|
|
816b99cee0 | ||
|
|
bf349a7dcd | ||
|
|
db6504bf80 | ||
|
|
2019d573f8 | ||
|
|
d716fc15ec | ||
|
|
56e16608e4 | ||
|
|
c471fc135b | ||
|
|
5112cf87b1 | ||
|
|
3d7bac47ee | ||
|
|
2a38d2fa1e | ||
|
|
82e110c08d | ||
|
|
f4c96078a1 | ||
|
|
975adedabb | ||
|
|
14ec0eba0d | ||
|
|
307d5f6591 | ||
|
|
ff9df43356 | ||
|
|
802e91aa05 | ||
|
|
28ef44cdec | ||
|
|
0ee0f35301 | ||
|
|
cc99aa542e | ||
|
|
8514da4a9a | ||
|
|
4625b89528 | ||
|
|
b2a07196b7 | ||
|
|
0a8698ee4d | ||
|
|
ec8ad36c95 | ||
|
|
2c0c1b4bb2 | ||
|
|
6fa71bd13d | ||
|
|
06aa16e7d7 | ||
|
|
b0f6d53f49 | ||
|
|
d27ab6a00b | ||
|
|
f0fb859479 | ||
|
|
4c8a913377 | ||
|
|
c1684baffd | ||
|
|
bb45ebcce1 | ||
|
|
18138b1ab5 | ||
|
|
26665efc13 | ||
|
|
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,67 @@
|
||||
|
||||
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.4
|
||||
|
||||
**Security**:
|
||||
- [Low] `GET /api/settings` and `GET /api/settings?property=auth` no longer return cleartext secrets (`auth.key`, `totpSecret`, `adminPassword`, OIDC `clientSecret`, LDAP bind password, recaptcha secret, OnlyOffice secret); secret fields are replaced with `**hidden**` in the response, and the YAML export now redacts the recaptcha and LDAP secrets whose comments lacked the `secret:` marker (GHSA-892f-9h5p-mfc8) reporter @paulmossman
|
||||
|
||||
**New Features**:
|
||||
- feat(search): sidebar File actions and close button in advanced search (#3303) (#2479) (#3303)
|
||||
|
||||
**Bug Fixes**:
|
||||
- Deleting a directory now releases folder quotas rooted inside it instead of leaving them charged for files that no longer exist; applies to API and WebDAV deletes (#3163) (#3235)
|
||||
- Bulk and public share deletes now release accounted quota usage; nested folder quotas reset when an ancestor is removed (#3163) (#3305)
|
||||
- Directory listings skip entries that return permission denied or not-found on per-entry stat instead of failing the whole folder (e.g. unreadable NTFS junctions on CIFS); skipped entries are omitted from the index like `ls -l` (#3184)
|
||||
- Preview generation for webp broken (#3254) (#3259)
|
||||
- Share banner never shows to public visitors, returns bannerUrl but not banner (#3246) (#3250)
|
||||
- fix html/markdown relative file preview support (#3131) (#3129) (#2355)
|
||||
- fix(archive): extract tar hard links instead of dropping them (#3234)
|
||||
- fix(auth): apply stored permission caps for customized API tokens with no global flags enabled (#3255)
|
||||
- Archive extraction writes regular and hard-link entries at the entry path instead of following an existing symlink at that path (tar and zip) (#3265)
|
||||
- fix(move-copy): require choosing a destination source in advanced search when none can be inferred, instead of silently copying to the source root (#3276)
|
||||
- fix(stream): single-file shares can now stream their own media on Windows by using slash-based path handling (#3270) (#3271)
|
||||
- fix(users): `lockPassword` is now enforced on self-service password changes; non-admins get 403 while admin resets still work (#3279) (#3283)
|
||||
- fix(analytics): the FreeBSD release binary now reports `freebsd` in `system.os` instead of `other` (#3244) (#3284)
|
||||
- fix(frontend): share expiration now reads backend Unix-seconds timestamps correctly instead of showing dates decades off (#3247)
|
||||
- fix(frontend): video on iPhone now uses the native fullscreen player instead of the in-page fallback (#3113) (#3288)
|
||||
- fix(frontend): the video loading spinner no longer stays on for the rest of playback when the fetch stalls but the buffer can continue (#3287) (#3289)
|
||||
- fix(frontend): the share banner is now shown to public visitors, using `bannerUrl` instead of the never-set `banner` field (#3246) (#3250)
|
||||
- fix(preview): camera RAW files (Panasonic `.rw2`/`.raw`/`.rwl`, Olympus `.orf`, `.cr2`/`.dng`) extract embedded previews from TIFF strips/SubIFDs and no longer return 415 when EXIF decoding fails (#3219) (#3243)
|
||||
- fix(indexing): restore lazy listing persistence with atomic folder-size deltas and ancestor propagation, and force a full scan for scanners that missed their slot while the process was down (#3260)
|
||||
- fix(indexing): directory listings skip entries that cannot be accessed or have vanished instead of failing the whole listing; remaining errors still fail and fully-vanished nonempty directories return an error (#3292)
|
||||
- fix(frontend): redirects from `/files` stay inside the files area so a source named "Files" no longer leaves an empty view after login (#3167) (#3304)
|
||||
|
||||
## 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 {
|
||||
|
||||
@@ -3,7 +3,6 @@ package activity
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
@@ -11,14 +10,19 @@ import (
|
||||
func remoteIP(r *http.Request) string {
|
||||
cfg := &settings.Config
|
||||
|
||||
// Forwarded client headers are only honored when the socket peer is a
|
||||
// configured trusted proxy; otherwise a direct client could spoof its IP.
|
||||
trustedPeer := cfg.Http.TrustProxyHeaders && settings.IsTrustedProxyPeer(r)
|
||||
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if cfg.Http.TrustProxyHeaders && xff != "" {
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[0])
|
||||
if trustedPeer && xff != "" {
|
||||
if ip := settings.TrustedProxyClientIP(xff); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
xri := r.Header.Get("X-Real-IP")
|
||||
if cfg.Http.TrustProxyHeaders && xri != "" {
|
||||
if trustedPeer && xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -330,6 +335,15 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*
|
||||
if info.Type == "directory" && opts.HideFileExt != "" {
|
||||
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
|
||||
}
|
||||
if info.Type == "directory" && opts.Expand {
|
||||
// Expanded listings without extended attrs skip persistDirectoryListing
|
||||
// (their placeholder preview values must not overwrite index rows), so
|
||||
// refresh the index lazily — otherwise folder sizes here never update.
|
||||
// Capture indexPath now: the deferred finalizeResponse strips the user
|
||||
// scope from response.Path before returning.
|
||||
// Coalesced per path so repeated listings do not run parallel refreshes.
|
||||
idx.RefreshDirectoryAsync(idx.MakeIndexPath(response.Path, true).String())
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
// Process directory metadata if requested
|
||||
@@ -629,47 +643,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 +916,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 +972,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)}
|
||||
|
||||
@@ -301,7 +301,7 @@ func detectRuntime() string {
|
||||
|
||||
func mapSystemOS(goos string) string {
|
||||
switch goos {
|
||||
case "linux", "windows", "darwin":
|
||||
case "linux", "windows", "darwin", "freebsd":
|
||||
return goos
|
||||
default:
|
||||
return "other"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package analytics
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMapSystemOS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
want string
|
||||
}{
|
||||
{name: "linux", goos: "linux", want: "linux"},
|
||||
{name: "windows", goos: "windows", want: "windows"},
|
||||
{name: "darwin", goos: "darwin", want: "darwin"},
|
||||
{name: "freebsd release build", goos: "freebsd", want: "freebsd"},
|
||||
{name: "unsupported goos", goos: "plan9", want: "other"},
|
||||
{name: "empty", goos: "", want: "other"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := mapSystemOS(tt.goos); got != tt.want {
|
||||
t.Errorf("mapSystemOS(%q) = %q, want %q", tt.goos, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
|
||||
},
|
||||
},
|
||||
}
|
||||
claim.Minimal = &minimal
|
||||
if !minimal {
|
||||
claim.Permissions = users.SanitizeTokenPermissions(perms)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -24,7 +24,11 @@ func HasAnyGlobalPermission(p Permissions) bool {
|
||||
}
|
||||
|
||||
// IsMinimalApiToken reports whether the token inherits the owner's globals at auth time
|
||||
// rather than embedding its own global permission caps in the JWT.
|
||||
// rather than applying stored global permission caps.
|
||||
func IsMinimalApiToken(t AuthToken) bool {
|
||||
if t.Minimal != nil {
|
||||
return *t.Minimal
|
||||
}
|
||||
// Legacy tokens lack minimal; empty stored global caps implied minimal (WebDAV-style).
|
||||
return !HasAnyGlobalPermission(t.Permissions)
|
||||
}
|
||||
@@ -45,6 +45,14 @@ func TestIsMinimalApiToken(t *testing.T) {
|
||||
t.Fatal("custom token with global caps should not be minimal")
|
||||
}
|
||||
if !IsMinimalApiToken(AuthToken{Permissions: Permissions{Modify: true}}) {
|
||||
t.Fatal("token without global caps should be minimal")
|
||||
t.Fatal("legacy token without global caps should be minimal")
|
||||
}
|
||||
minimal := true
|
||||
if !IsMinimalApiToken(AuthToken{Minimal: &minimal}) {
|
||||
t.Fatal("explicit minimal token should be minimal")
|
||||
}
|
||||
customized := false
|
||||
if IsMinimalApiToken(AuthToken{Minimal: &customized}) {
|
||||
t.Fatal("explicit customized token with empty caps should not be minimal")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -104,6 +105,8 @@ type AuthToken struct {
|
||||
BelongsTo uint64 `json:"belongsTo,omitempty"` // legacy stored metadata only, never trusted for auth; identity is always the hashed_tokens lookup
|
||||
IssuedAt int64 `json:"issuedAt,omitempty"`
|
||||
ExpiresAt int64 `json:"expiresAt,omitempty"`
|
||||
// Minimal distinguishes owner-inheriting tokens from customized caps. Nil means legacy (infer from stored permissions).
|
||||
Minimal *bool `json:"minimal,omitempty"`
|
||||
Permissions Permissions `json:"Permissions,omitempty"`
|
||||
}
|
||||
|
||||
@@ -113,15 +116,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 +259,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,8 +13,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
@@ -209,7 +209,7 @@ func generateMaskableIcon(sourceData []byte, icon iconSize) error {
|
||||
// Android adaptive-icon content ratio (72/108 ≈ 2/3) keeps square art inside every mask shape
|
||||
inner := icon.size * 2 / 3
|
||||
art := imaging.Fit(img, inner, inner, imaging.Lanczos)
|
||||
canvas := imaging.PasteCenter(imaging.New(icon.size, icon.size, maskableBackground(img)), art)
|
||||
canvas := imaging.OverlayCenter(imaging.New(icon.size, icon.size, maskableBackground(img)), art, 1)
|
||||
|
||||
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
|
||||
outFile, err := os.Create(outputPath)
|
||||
|
||||
@@ -1,13 +1,94 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
func TestGenerateMaskableIconKeepsCanvasOpaque(t *testing.T) {
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = t.TempDir()
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCacheDir })
|
||||
if err := os.MkdirAll(settings.PWAIconsCacheDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
background color.Color
|
||||
source func(*image.RGBA)
|
||||
}{
|
||||
{
|
||||
name: "transparent padding",
|
||||
background: color.White,
|
||||
source: func(img *image.RGBA) {
|
||||
img.Set(5, 5, color.RGBA{R: 20, G: 40, B: 200, A: 255})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opaque edge",
|
||||
background: color.NRGBA{R: 200, G: 100, B: 50, A: 255},
|
||||
source: func(img *image.RGBA) {
|
||||
for x := 0; x < 12; x++ {
|
||||
img.Set(x, 0, color.RGBA{R: 200, G: 100, B: 50, A: 255})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
source := image.NewRGBA(image.Rect(0, 0, 12, 12))
|
||||
tt.source(source)
|
||||
var sourceData bytes.Buffer
|
||||
if err := png.Encode(&sourceData, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const size = 96
|
||||
icon := iconSize{size: size, name: "maskable.png", maskable: true}
|
||||
if err := generateMaskableIcon(sourceData.Bytes(), icon); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := imaging.Open(filepath.Join(settings.PWAIconsCacheDir(), icon.name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if output.Bounds().Dx() != size || output.Bounds().Dy() != size {
|
||||
t.Fatalf("got bounds %v, want %dx%d", output.Bounds(), size, size)
|
||||
}
|
||||
want, ok := color.NRGBAModel.Convert(tt.background).(color.NRGBA)
|
||||
if !ok {
|
||||
t.Fatalf("background color %#v is not NRGBA", tt.background)
|
||||
}
|
||||
for y := 0; y < size; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
outsideArt := x < 16 || x >= 80 || y < 16 || y >= 80
|
||||
got, ok := color.NRGBAModel.Convert(output.At(x, y)).(color.NRGBA)
|
||||
if !ok {
|
||||
t.Fatalf("pixel (%d,%d) is not NRGBA", x, y)
|
||||
}
|
||||
if got.A != 255 {
|
||||
t.Fatalf("pixel (%d,%d) has alpha %d, want opaque", x, y, got.A)
|
||||
}
|
||||
if outsideArt && got != want {
|
||||
t.Fatalf("pixel (%d,%d) got %#v, want background %#v", x, y, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskableBackgroundOpaqueCorner(t *testing.T) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
|
||||
img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
|
||||
|
||||
@@ -78,12 +78,10 @@ func extractTIFFEmbeddedPreview(f *os.File) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ex, err := extimagemeta.Decode(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var candidates []previewCandidate
|
||||
if ex, err := extimagemeta.Decode(f); err == nil {
|
||||
candidates = previewCandidates(ex)
|
||||
}
|
||||
|
||||
candidates := previewCandidates(ex)
|
||||
if scanCandidates, scanErr := collectTIFFPreviewCandidates(f); scanErr == nil {
|
||||
candidates = append(candidates, scanCandidates...)
|
||||
}
|
||||
@@ -115,6 +113,9 @@ func previewCandidates(ex exif.Exif) []previewCandidate {
|
||||
out = append(out, previewCandidate{offset: ifd.ImageOffset, length: ifd.ImageLength})
|
||||
}
|
||||
}
|
||||
for _, sub := range ex.SubIFDs {
|
||||
appendIFD(sub)
|
||||
}
|
||||
appendIFD(ex.IFD1)
|
||||
appendIFD(ex.IFD2)
|
||||
|
||||
|
||||
@@ -47,15 +47,16 @@ func GetOrientation(ctx context.Context, path string) string {
|
||||
default:
|
||||
ex, err = extimagemeta.Decode(f)
|
||||
}
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if ex.IFD0.Orientation == 0 {
|
||||
return ""
|
||||
if err == nil && ex.IFD0.Orientation != 0 {
|
||||
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(ex.IFD0.Orientation))
|
||||
}
|
||||
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(ex.IFD0.Orientation))
|
||||
if !isHEICExtension(ext) {
|
||||
if orient := extractTIFFOrientation(f); orient != 0 {
|
||||
return tag.ValueNameFor(tag.IFD0, tag.TagOrientation, uint32(orient))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -9,11 +9,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
tiffTagStripOffsets = 0x0111
|
||||
tiffTagOrientation = 0x0112
|
||||
tiffTagStripByteCounts = 0x0117
|
||||
tiffTagSubIFDs = 0x014a
|
||||
tiffTagJpgFromRaw = 0x002e
|
||||
tiffTagJPEGInterchange = 0x0201
|
||||
tiffTagJPEGInterchangeLen = 0x0202
|
||||
tiffIFDScanLimit = 4 * 1024 * 1024
|
||||
tiffMagicStandard = 42
|
||||
tiffMagicPanasonicRW2 = 0x0055
|
||||
tiffMagicOlympusORF1 = 0x4f52
|
||||
tiffMagicOlympusORF2 = 0x5352
|
||||
tiffTypeByte = 1
|
||||
tiffTypeASCII = 2
|
||||
tiffTypeShort = 3
|
||||
@@ -36,30 +43,47 @@ type tiffTag struct {
|
||||
valueOffset uint32
|
||||
}
|
||||
|
||||
// collectTIFFPreviewCandidates walks TIFF IFDs (including SubIFDs) for embedded
|
||||
// JPEG preview offset/length pairs that imagemeta does not surface.
|
||||
func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
|
||||
// readTIFFScanWindow reads the bounded IFD scan window (up to tiffIFDScanLimit bytes) of f.
|
||||
// It validates the 8-byte TIFF header first, so non-TIFF files such as JPEGs are rejected
|
||||
// without allocating or reading the full window. ok is false for non-TIFF or too-short files.
|
||||
func readTIFFScanWindow(f *os.File) (header []byte, order binary.ByteOrder, ifd0 uint32, ok bool, err error) {
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, 0, false, err
|
||||
}
|
||||
scanSize := info.Size()
|
||||
if scanSize > tiffIFDScanLimit {
|
||||
scanSize = tiffIFDScanLimit
|
||||
}
|
||||
if scanSize < 8 {
|
||||
return nil, nil
|
||||
return nil, nil, 0, false, nil
|
||||
}
|
||||
header := make([]byte, scanSize)
|
||||
var magic [8]byte
|
||||
if _, err = f.ReadAt(magic[:], 0); err != nil {
|
||||
if err == io.EOF {
|
||||
return nil, nil, 0, false, nil
|
||||
}
|
||||
return nil, nil, 0, false, err
|
||||
}
|
||||
if _, _, ok = tiffHeaderIFD0(magic[:]); !ok {
|
||||
return nil, nil, 0, false, nil
|
||||
}
|
||||
header = make([]byte, scanSize)
|
||||
n, err := f.ReadAt(header, 0)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
return nil, nil, 0, false, err
|
||||
}
|
||||
header = header[:n]
|
||||
order, ifd0, ok = tiffHeaderIFD0(header)
|
||||
return header, order, ifd0, ok, nil
|
||||
}
|
||||
|
||||
order, ifd0, ok := tiffHeaderIFD0(header)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
// collectTIFFPreviewCandidates walks TIFF IFDs (including SubIFDs) for embedded
|
||||
// JPEG preview offset/length pairs that imagemeta does not surface.
|
||||
func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
|
||||
header, order, ifd0, ok, err := readTIFFScanWindow(f)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[uint32]struct{})
|
||||
@@ -97,6 +121,7 @@ func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
|
||||
for _, ifd := range ifds {
|
||||
out = appendPreviewCandidates(out, jpgFromRawCandidates(ifd))
|
||||
out = appendPreviewCandidates(out, jpegInterchangeCandidates(ifd))
|
||||
out = appendPreviewCandidates(out, stripPreviewCandidates(ifd, order))
|
||||
if len(out) >= maxTIFFPreviewCandidates {
|
||||
break
|
||||
}
|
||||
@@ -104,6 +129,24 @@ func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// extractTIFFOrientation reads standard TIFF IFD0 Orientation (0x0112) as a fallback
|
||||
// when full EXIF decoding fails.
|
||||
func extractTIFFOrientation(f *os.File) uint16 {
|
||||
header, order, ifd0, ok, err := readTIFFScanWindow(f)
|
||||
if err != nil || !ok || ifd0 == 0 {
|
||||
return 0
|
||||
}
|
||||
ifd, _, _, err := parseTIFFIFD(header, order, ifd0)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
tag, ok := ifd.tags[tiffTagOrientation]
|
||||
if !ok || tag.count == 0 {
|
||||
return 0
|
||||
}
|
||||
return tiffTagFirstUint16(tag, order)
|
||||
}
|
||||
|
||||
func appendPreviewCandidates(dst, src []previewCandidate) []previewCandidate {
|
||||
remaining := maxTIFFPreviewCandidates - len(dst)
|
||||
if remaining <= 0 {
|
||||
@@ -144,6 +187,48 @@ func jpegInterchangeCandidates(ifd tiffIFD) []previewCandidate {
|
||||
return []previewCandidate{{offset: offset, length: length}}
|
||||
}
|
||||
|
||||
func stripPreviewCandidates(ifd tiffIFD, order binary.ByteOrder) []previewCandidate {
|
||||
offTag, okOff := ifd.tags[tiffTagStripOffsets]
|
||||
lenTag, okLen := ifd.tags[tiffTagStripByteCounts]
|
||||
if !okOff || !okLen || offTag.count != 1 || lenTag.count != 1 {
|
||||
return nil
|
||||
}
|
||||
offset := tiffTagScalarUint32(offTag, order)
|
||||
length := tiffTagScalarUint32(lenTag, order)
|
||||
if offset == 0 || length <= 4 || length > maxPreviewReadSize {
|
||||
return nil
|
||||
}
|
||||
return []previewCandidate{{offset: offset, length: length}}
|
||||
}
|
||||
|
||||
func tiffTagScalarUint32(t tiffTag, order binary.ByteOrder) uint32 {
|
||||
switch t.typ {
|
||||
case tiffTypeLong, tiffTypeIFD:
|
||||
return t.valueOffset
|
||||
case tiffTypeShort:
|
||||
return uint32(tiffTagFirstUint16(t, order))
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tiffTagFirstUint16(t tiffTag, order binary.ByteOrder) uint16 {
|
||||
switch t.typ {
|
||||
case tiffTypeShort:
|
||||
if order == binary.BigEndian {
|
||||
return uint16(t.valueOffset >> 16)
|
||||
}
|
||||
return uint16(t.valueOffset & 0xffff)
|
||||
case tiffTypeLong:
|
||||
if t.valueOffset > 0xffff {
|
||||
return 0
|
||||
}
|
||||
return uint16(t.valueOffset)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
|
||||
if len(data) < 8 {
|
||||
return nil, 0, false
|
||||
@@ -157,7 +242,9 @@ func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
|
||||
default:
|
||||
return nil, 0, false
|
||||
}
|
||||
if order.Uint16(data[2:4]) != 42 {
|
||||
switch order.Uint16(data[2:4]) {
|
||||
case tiffMagicStandard, tiffMagicPanasonicRW2, tiffMagicOlympusORF1, tiffMagicOlympusORF2:
|
||||
default:
|
||||
return nil, 0, false
|
||||
}
|
||||
return order, order.Uint32(data[4:8]), true
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestCollectTIFFPreviewCandidatesIgnoresMalformedOffsets(t *testing.T) {
|
||||
func TestCollectTIFFPreviewCandidatesIgnoresMalformedSubIFDOffset(t *testing.T) {
|
||||
data := buildSyntheticTIFF(tiffFixture{
|
||||
thumbnailJPEG: smallJPEG,
|
||||
subIFDJPEG: largeJPEG,
|
||||
subIFDJPEG: largeJPEG,
|
||||
badJpgFromRaw: true,
|
||||
})
|
||||
f := openBytesFile(t, data)
|
||||
@@ -114,6 +114,138 @@ func TestTiffTagUint32sSupportsIFDType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmbeddedPreviewFallbackWhenDecodeFails(t *testing.T) {
|
||||
// Synthetic ARW where SubIFD offset is placed after IFD2, and a SubIFD tag
|
||||
// points past EOF so extimagemeta.Decode fails with EOF, while IFD2 holds a
|
||||
// valid JPEGInterchangeFormat preview.
|
||||
order := binary.LittleEndian
|
||||
buf := make([]byte, 32*1024)
|
||||
copy(buf[0:2], []byte("II"))
|
||||
order.PutUint16(buf[2:4], tiffMagicStandard)
|
||||
ifd0Off := uint32(8)
|
||||
order.PutUint32(buf[4:8], ifd0Off)
|
||||
|
||||
thumbOff := uint32(4096)
|
||||
copy(buf[thumbOff:], smallJPEG)
|
||||
largeOff := uint32(8192)
|
||||
copy(buf[largeOff:], largeJPEG)
|
||||
|
||||
ifd1Off := uint32(256)
|
||||
ifd2Off := uint32(512)
|
||||
subIFDOff := uint32(768)
|
||||
|
||||
writeIFD := func(offset uint32, entries []tiffEntry, next uint32) {
|
||||
off := int(offset)
|
||||
order.PutUint16(buf[off:off+2], uint16(len(entries)))
|
||||
entryBase := off + 2
|
||||
for i, entry := range entries {
|
||||
pos := entryBase + i*12
|
||||
order.PutUint16(buf[pos:pos+2], entry.tag)
|
||||
order.PutUint16(buf[pos+2:pos+4], entry.typ)
|
||||
order.PutUint32(buf[pos+4:pos+8], entry.count)
|
||||
order.PutUint32(buf[pos+8:pos+12], entry.value)
|
||||
}
|
||||
order.PutUint32(buf[entryBase+len(entries)*12:entryBase+len(entries)*12+4], next)
|
||||
}
|
||||
|
||||
writeIFD(ifd0Off, []tiffEntry{
|
||||
{tag: tiffTagOrientation, typ: tiffTypeShort, count: 1, value: 6},
|
||||
{tag: tiffTagSubIFDs, typ: tiffTypeLong, count: 1, value: subIFDOff},
|
||||
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
|
||||
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(smallJPEG))},
|
||||
}, ifd1Off)
|
||||
|
||||
writeIFD(ifd1Off, []tiffEntry{
|
||||
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
|
||||
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(smallJPEG))},
|
||||
}, ifd2Off)
|
||||
|
||||
writeIFD(ifd2Off, []tiffEntry{
|
||||
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: largeOff},
|
||||
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(largeJPEG))},
|
||||
}, 0)
|
||||
|
||||
// SubIFD has a StripOffsets tag with count=2 pointing beyond file end so Decode fails with EOF.
|
||||
writeIFD(subIFDOff, []tiffEntry{
|
||||
{tag: tiffTagStripOffsets, typ: tiffTypeLong, count: 2, value: uint32(len(buf) + 4096)},
|
||||
}, 0)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "sample.arw")
|
||||
if err := os.WriteFile(path, buf[:int(largeOff)+len(largeJPEG)], 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preview, err := ExtractEmbeddedPreview(t.Context(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractEmbeddedPreview() err = %v", err)
|
||||
}
|
||||
if len(preview) != len(largeJPEG) {
|
||||
t.Fatalf("ExtractEmbeddedPreview() len = %d, want %d", len(preview), len(largeJPEG))
|
||||
}
|
||||
if got := GetOrientation(t.Context(), path); got != "Rotate 90 CW" {
|
||||
t.Fatalf("GetOrientation() = %q, want %q", got, "Rotate 90 CW")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmbeddedPreviewSupportsRW2AndStripPreview(t *testing.T) {
|
||||
order := binary.LittleEndian
|
||||
buf := make([]byte, 16*1024)
|
||||
copy(buf[0:2], []byte("II"))
|
||||
order.PutUint16(buf[2:4], tiffMagicPanasonicRW2)
|
||||
ifd0Off := uint32(8)
|
||||
order.PutUint32(buf[4:8], ifd0Off)
|
||||
|
||||
largeOff := uint32(4096)
|
||||
copy(buf[largeOff:], largeJPEG)
|
||||
|
||||
entries := []tiffEntry{
|
||||
{tag: tiffTagStripOffsets, typ: tiffTypeLong, count: 1, value: largeOff},
|
||||
{tag: tiffTagStripByteCounts, typ: tiffTypeLong, count: 1, value: uint32(len(largeJPEG))},
|
||||
}
|
||||
order.PutUint16(buf[ifd0Off:ifd0Off+2], uint16(len(entries)))
|
||||
for i, entry := range entries {
|
||||
pos := int(ifd0Off) + 2 + i*12
|
||||
order.PutUint16(buf[pos:pos+2], entry.tag)
|
||||
order.PutUint16(buf[pos+2:pos+4], entry.typ)
|
||||
order.PutUint32(buf[pos+4:pos+8], entry.count)
|
||||
order.PutUint32(buf[pos+8:pos+12], entry.value)
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "sample.rw2")
|
||||
if err := os.WriteFile(path, buf[:int(largeOff)+len(largeJPEG)], 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preview, err := ExtractEmbeddedPreview(t.Context(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractEmbeddedPreview() err = %v", err)
|
||||
}
|
||||
if len(preview) != len(largeJPEG) {
|
||||
t.Fatalf("ExtractEmbeddedPreview() len = %d, want %d", len(preview), len(largeJPEG))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmbeddedPreviewDSC02672(t *testing.T) {
|
||||
path := "/Users/steffag/Downloads/DSC02672-first-4MB.ARW"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skip(path)
|
||||
}
|
||||
|
||||
got, err := ExtractEmbeddedPreview(t.Context(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsJPEG(got) {
|
||||
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
|
||||
}
|
||||
if len(got) != 2954147 {
|
||||
t.Fatalf("preview size = %d, want 2954147", len(got))
|
||||
}
|
||||
if orient := GetOrientation(t.Context(), path); orient != "Horizontal (normal)" {
|
||||
t.Fatalf("GetOrientation() = %q, want Horizontal (normal)", orient)
|
||||
}
|
||||
}
|
||||
|
||||
type tiffFixture struct {
|
||||
thumbnailJPEG []byte
|
||||
subIFDJPEG []byte
|
||||
@@ -195,8 +327,8 @@ func buildSyntheticTIFF(fix tiffFixture) []byte {
|
||||
}
|
||||
|
||||
type tiffEntry struct {
|
||||
tag, typ uint16
|
||||
count, value uint32
|
||||
tag, typ uint16
|
||||
count, value uint32
|
||||
}
|
||||
|
||||
func buildChainedTIFF(chainLen int) []byte {
|
||||
@@ -218,3 +350,22 @@ func buildChainedTIFF(chainLen int) []byte {
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
// Non-TIFF files (e.g. a JPEG reaching the orientation fallback) must be rejected from the
|
||||
// 8-byte header, not after reading the whole scan window.
|
||||
func TestReadTIFFScanWindowRejectsNonTIFFFromHeader(t *testing.T) {
|
||||
jpeg := append([]byte{0xff, 0xd8, 0xff, 0xe0}, make([]byte, 1<<20)...)
|
||||
header, _, _, ok, err := readTIFFScanWindow(openBytesFile(t, jpeg))
|
||||
if err != nil || ok || header != nil {
|
||||
t.Fatalf("got header=%d bytes ok=%v err=%v, want nil/false/nil", len(header), ok, err)
|
||||
}
|
||||
if got := extractTIFFOrientation(openBytesFile(t, jpeg)); got != 0 {
|
||||
t.Fatalf("orientation = %d, want 0", got)
|
||||
}
|
||||
|
||||
tiff := buildSyntheticTIFF(tiffFixture{thumbnailJPEG: smallJPEG})
|
||||
header, _, ifd0, ok, err := readTIFFScanWindow(openBytesFile(t, tiff))
|
||||
if err != nil || !ok || ifd0 == 0 || len(header) != len(tiff) {
|
||||
t.Fatalf("tiff: header=%d ok=%v ifd0=%d err=%v", len(header), ok, ifd0, err)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,18 @@ func (x Format) toImaging() imaging.Format {
|
||||
}
|
||||
}
|
||||
|
||||
// encodable reports whether imaging can encode this format.
|
||||
// imaging only encodes JPEG, PNG, GIF, TIFF and BMP; webp and the netpbm
|
||||
// formats are decode-only, so previews for them must be written as JPEG.
|
||||
func (x Format) encodable() bool {
|
||||
switch x {
|
||||
case FormatJpeg, FormatPng, FormatGif, FormatTiff, FormatBmp:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
ENUM(
|
||||
high
|
||||
@@ -203,7 +215,9 @@ func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
|
||||
var wrappedReader = in
|
||||
var err error
|
||||
|
||||
if opts.Format == FormatHeic {
|
||||
// HEIC has no encoder and webp/netpbm are decode-only in imaging; fall back
|
||||
// to JPEG so preview generation does not fail with ErrUnsupportedFormat.
|
||||
if !opts.Format.encodable() {
|
||||
opts.Format = FormatJpeg
|
||||
}
|
||||
// Ensure JPEG quality is set based on quality setting
|
||||
|
||||
@@ -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)
|
||||
@@ -159,6 +161,14 @@ func TestService_Resize(t *testing.T) {
|
||||
},
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize webp falls back to jpeg": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Format: FormatWebp},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return openFile(t, "testdata/gray-sample.webp")
|
||||
},
|
||||
matcher: formatMatcher(FormatJpeg),
|
||||
},
|
||||
"resize with high quality": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityHigh},
|
||||
source: func(t *testing.T) afero.File {
|
||||
|
||||
@@ -272,15 +272,6 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
|
||||
case previewTypeImage:
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
if iteminfo.IsRawImage(ext) {
|
||||
if s.ffmpegService != nil {
|
||||
previewBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err == nil && len(previewBytes) >= minPreviewSize {
|
||||
return previewBytes, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return nil, ErrUnsupportedFormat
|
||||
}
|
||||
return s.generateImagePreview(ctx, file, previewSize)
|
||||
|
||||
@@ -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,
|
||||
@@ -671,6 +715,98 @@ func ForceCommitSessionQuota(sessionID string, deltaBytes int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAdjustments holds accounted quota updates to apply after a successful delete.
|
||||
type DeleteAdjustments struct {
|
||||
Deltas map[string]int64
|
||||
Resets []string
|
||||
}
|
||||
|
||||
// PrepareAccountedDelete captures quota adjustments while deleted paths are still indexed.
|
||||
// Folder quotas rooted inside a deleted directory are reset; covering quotas use itemBytes.
|
||||
func PrepareAccountedDelete(principal *users.User, sourceName, sourcePath, indexPath, shareHash string, isDir bool, itemBytes int64) DeleteAdjustments {
|
||||
adj := DeleteAdjustments{Deltas: make(map[string]int64)}
|
||||
if principal == nil {
|
||||
return adj
|
||||
}
|
||||
indexPath = normalizeQuotaPath(indexPath)
|
||||
for _, fq := range ListFolderQuotasForSource(sourcePath) {
|
||||
if fq.LimitBytes <= 0 || (fq.UserID != 0 && fq.UserID != principal.ID) {
|
||||
continue
|
||||
}
|
||||
if isDir && indexPathCovers(indexPath, fq.Path) {
|
||||
if quota.EffectiveMeter(configuredFolderMeter(fq), sourceName, fq.Path) != quota.MeterAccounted {
|
||||
continue
|
||||
}
|
||||
adj.Resets = append(adj.Resets, fq.ID)
|
||||
continue
|
||||
}
|
||||
if !indexPathCovers(fq.Path, indexPath) {
|
||||
continue
|
||||
}
|
||||
if quota.EffectiveMeter(configuredFolderMeter(fq), sourceName, fq.Path) != quota.MeterAccounted {
|
||||
continue
|
||||
}
|
||||
if itemBytes > 0 {
|
||||
adj.Deltas[fq.ID] = -itemBytes
|
||||
}
|
||||
}
|
||||
if itemBytes > 0 {
|
||||
for _, id := range accountedScopeQuotaIDs(principal, sourceName, sourcePath) {
|
||||
adj.Deltas[id] = -itemBytes
|
||||
}
|
||||
if shareHash != "" {
|
||||
adj.Deltas[quota.ShareQuotaID(shareHash)] = -itemBytes
|
||||
}
|
||||
}
|
||||
return adj
|
||||
}
|
||||
|
||||
// ApplyAccountedDelete applies prepared adjustments only after a successful deletion.
|
||||
func ApplyAccountedDelete(adj DeleteAdjustments) error {
|
||||
quotasMux.Lock()
|
||||
defer quotasMux.Unlock()
|
||||
defer signalQuotaFlush()
|
||||
for id, delta := range adj.Deltas {
|
||||
if err := applyCounterDeltaLocked(id, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range adj.Resets {
|
||||
if err := resetAccountedUsageLocked(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetAccountedUsageLocked(quotaID string) error {
|
||||
if err := ensureQuotaCounterMemLocked(quotaID); err != nil {
|
||||
return err
|
||||
}
|
||||
c := quotaCounters[quotaID]
|
||||
c.UsedBytes = 0
|
||||
c.Dirty = true
|
||||
markQuotaCounterDirty(quotaID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func accountedScopeQuotaIDs(principal *users.User, sourceName, sourcePath string) []string {
|
||||
var ids []string
|
||||
for _, bs := range principal.BackendScopes {
|
||||
if bs.Path != sourcePath || bs.Quota == nil || bs.Quota.LimitBytes <= 0 {
|
||||
continue
|
||||
}
|
||||
configured := bs.Quota.Meter
|
||||
if configured == "" {
|
||||
configured = quota.MeterIndexScope
|
||||
}
|
||||
if quota.EffectiveMeter(configured, sourceName, scopeIndexPath(principal, sourceName)) == quota.MeterAccounted {
|
||||
ids = append(ids, bs.Quota.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ApplyAccountedUsageDelta adjusts accounted and share quota counters for a path (negative delta frees space).
|
||||
func ApplyAccountedUsageDelta(principal *users.User, sourceName, sourcePath, indexPath, shareHash string, delta int64) error {
|
||||
if delta == 0 || principal == nil {
|
||||
@@ -691,21 +827,8 @@ func ApplyAccountedUsageDelta(principal *users.User, sourceName, sourcePath, ind
|
||||
}
|
||||
}
|
||||
|
||||
for _, bs := range principal.BackendScopes {
|
||||
if bs.Path != sourcePath || bs.Quota == nil || bs.Quota.LimitBytes <= 0 {
|
||||
continue
|
||||
}
|
||||
sq := bs.Quota
|
||||
configured := sq.Meter
|
||||
if configured == "" {
|
||||
configured = quota.MeterIndexScope
|
||||
}
|
||||
scopePath := scopeIndexPath(principal, sourceName)
|
||||
effective := quota.EffectiveMeter(configured, sourceName, scopePath)
|
||||
if effective != quota.MeterAccounted {
|
||||
continue
|
||||
}
|
||||
if err := applyCounterDeltaLocked(sq.ID, delta); err != nil {
|
||||
for _, id := range accountedScopeQuotaIDs(principal, sourceName, sourcePath) {
|
||||
if err := applyCounterDeltaLocked(id, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
)
|
||||
|
||||
func TestPrepareAccountedDelete(t *testing.T) {
|
||||
oldFolders, oldSources, oldCounters := folderQuotasByID, folderQuotasBySource, quotaCounters
|
||||
folderQuotasByID = make(map[string]*quota.FolderQuota)
|
||||
folderQuotasBySource = make(map[string][]string)
|
||||
quotaCounters = make(map[string]*quotaCounterMem)
|
||||
t.Cleanup(func() {
|
||||
folderQuotasByID, folderQuotasBySource, quotaCounters = oldFolders, oldSources, oldCounters
|
||||
indexing.ClearTestIndices()
|
||||
})
|
||||
indexing.SetTestIndex("delete", "/source")
|
||||
idx := indexing.GetIndex("delete")
|
||||
idx.SetFolderSize("/public/", 60)
|
||||
idx.SetFolderSize("/public/nested/", 50)
|
||||
idx.SetFolderSize("/public/nested/deeper/", 30)
|
||||
principal := &users.User{ID: 1, BackendScopes: []users.BackendScope{{Path: "/source", Scope: "/", Quota: &users.ScopeQuota{ID: "scope", LimitBytes: 1000, Meter: quota.MeterAccounted}}}}
|
||||
for _, q := range []quota.FolderQuota{
|
||||
{ID: "cover", Source: "/source", Path: "/", Meter: quota.MeterAccounted},
|
||||
{ID: "exact", Source: "/source", Path: "/public", Meter: quota.MeterAccounted},
|
||||
{ID: "nested", Source: "/source", Path: "/public/nested", Meter: quota.MeterAccounted, UserID: 1},
|
||||
{ID: "deeper", Source: "/source", Path: "/public/nested/deeper", Meter: quota.MeterAccounted},
|
||||
{ID: "missing", Source: "/source", Path: "/public/missing", Meter: quota.MeterAccounted},
|
||||
{ID: "sibling", Source: "/source", Path: "/sibling", Meter: quota.MeterAccounted},
|
||||
{ID: "prefix", Source: "/source", Path: "/public-other", Meter: quota.MeterAccounted},
|
||||
{ID: "other-user", Source: "/source", Path: "/public/nested", Meter: quota.MeterAccounted, UserID: 2},
|
||||
{ID: "other-source", Source: "/other", Path: "/public/nested", Meter: quota.MeterAccounted},
|
||||
{ID: "index", Source: "/source", Path: "/public/nested", Meter: quota.MeterIndexSize},
|
||||
} {
|
||||
q.LimitBytes = 1000
|
||||
storeFolderQuota(q)
|
||||
quotaCounters[q.ID] = "aCounterMem{UsedBytes: 100, ReservedBytes: 7}
|
||||
}
|
||||
shareID := quota.ShareQuotaID("share")
|
||||
quotaCounters["scope"] = "aCounterMem{UsedBytes: 100, ReservedBytes: 7}
|
||||
quotaCounters[shareID] = "aCounterMem{UsedBytes: 100, ReservedBytes: 7}
|
||||
wantDeltas := map[string]int64{"cover": -60, "scope": -60, shareID: -60}
|
||||
wantResets := []string{"exact", "nested", "deeper", "missing"}
|
||||
got := PrepareAccountedDelete(principal, "delete", "/source", "/public", "share", true, 60)
|
||||
if !reflect.DeepEqual(got.Deltas, wantDeltas) {
|
||||
t.Fatalf("deltas = %v, want %v", got.Deltas, wantDeltas)
|
||||
}
|
||||
if !slicesEqual(got.Resets, wantResets) {
|
||||
t.Fatalf("resets = %v, want %v", got.Resets, wantResets)
|
||||
}
|
||||
for id, delta := range wantDeltas {
|
||||
quotaCounters[id].UsedBytes = -delta
|
||||
}
|
||||
quotaCounters["cover"].UsedBytes += 40
|
||||
for _, id := range wantResets {
|
||||
quotaCounters[id].UsedBytes = 100
|
||||
}
|
||||
if err := ApplyAccountedDelete(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for id, c := range quotaCounters {
|
||||
expected := int64(100)
|
||||
if _, ok := wantDeltas[id]; ok {
|
||||
expected = 0
|
||||
}
|
||||
if slices.Contains(wantResets, id) {
|
||||
expected = 0
|
||||
}
|
||||
if id == "cover" {
|
||||
expected = 40
|
||||
}
|
||||
if c.UsedBytes != expected || c.ReservedBytes != 7 {
|
||||
t.Errorf("%s: used=%d reserved=%d, want %d/7", id, c.UsedBytes, c.ReservedBytes, expected)
|
||||
}
|
||||
}
|
||||
for _, size := range []int64{0, -1} {
|
||||
got = PrepareAccountedDelete(principal, "delete", "/source", "/public", "", true, size)
|
||||
wantResetsUnknown := []string{"exact", "nested", "deeper", "missing"}
|
||||
if len(got.Deltas) != 0 || !slicesEqual(got.Resets, wantResetsUnknown) {
|
||||
t.Fatalf("unavailable/zero ancestor: deltas=%v resets=%v", got.Deltas, got.Resets)
|
||||
}
|
||||
}
|
||||
got = PrepareAccountedDelete(principal, "delete", "/source", "/public/nested/file", "", false, 5)
|
||||
if !reflect.DeepEqual(got, DeleteAdjustments{
|
||||
Deltas: map[string]int64{"cover": -5, "exact": -5, "nested": -5, "scope": -5},
|
||||
}) {
|
||||
t.Fatalf("file adjustments: %v", got)
|
||||
}
|
||||
empty := PrepareAccountedDelete(nil, "delete", "/source", "/public", "share", true, 60)
|
||||
if len(empty.Deltas) != 0 || len(empty.Resets) != 0 {
|
||||
t.Fatalf("nil principal: %v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareAccountedDeleteResetsWithoutIndexedSizes(t *testing.T) {
|
||||
oldFolders, oldSources, oldCounters := folderQuotasByID, folderQuotasBySource, quotaCounters
|
||||
folderQuotasByID = make(map[string]*quota.FolderQuota)
|
||||
folderQuotasBySource = make(map[string][]string)
|
||||
quotaCounters = make(map[string]*quotaCounterMem)
|
||||
t.Cleanup(func() {
|
||||
folderQuotasByID, folderQuotasBySource, quotaCounters = oldFolders, oldSources, oldCounters
|
||||
indexing.ClearTestIndices()
|
||||
})
|
||||
indexing.SetTestIndex("delete", "/source")
|
||||
principal := &users.User{ID: 1}
|
||||
nested := quota.FolderQuota{ID: "nested", Source: "/source", Path: "/public/nested", Meter: quota.MeterAccounted, LimitBytes: 1000}
|
||||
storeFolderQuota(nested)
|
||||
quotaCounters["nested"] = "aCounterMem{UsedBytes: 50, ReservedBytes: 3}
|
||||
got := PrepareAccountedDelete(principal, "delete", "/source", "/public", "", true, 0)
|
||||
if !slicesEqual(got.Resets, []string{"nested"}) {
|
||||
t.Fatalf("resets = %v, want nested reset without index sizes", got.Resets)
|
||||
}
|
||||
if err := ApplyAccountedDelete(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if used, reserved := GetQuotaCounterSnapshot("nested"); used != 0 || reserved != 3 {
|
||||
t.Fatalf("nested used=%d reserved=%d, want 0/3", used, reserved)
|
||||
}
|
||||
}
|
||||
|
||||
func slicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
ac := slices.Clone(a)
|
||||
bc := slices.Clone(b)
|
||||
slices.Sort(ac)
|
||||
slices.Sort(bc)
|
||||
return slices.Equal(ac, bc)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+300
-44
@@ -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,17 +1192,42 @@ 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)
|
||||
}
|
||||
|
||||
// removeSymlinkDestination removes destPath when it is a symlink so a regular-file
|
||||
// write creates the entry at destPath instead of following the link.
|
||||
func removeSymlinkDestination(destPath string) error {
|
||||
info, err := os.Lstat(destPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.Mode()&fs.ModeSymlink == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(destPath); err != nil {
|
||||
logger.Debugf("unarchive: remove symlink destination: %v", err)
|
||||
return fmt.Errorf("failed to write file from archive entry")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractArchivedRegularFile writes one regular file from src (closed by this function) and reapplies mode and mtime.
|
||||
func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.Time, src io.ReadCloser) error {
|
||||
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")
|
||||
}
|
||||
if err := removeSymlinkDestination(destPath); err != nil {
|
||||
return err
|
||||
}
|
||||
perm := mode.Perm()
|
||||
if perm == 0 {
|
||||
@@ -1106,15 +1235,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 +1256,69 @@ 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, "")
|
||||
// extractArchivedHardLink writes a copy of a regular file extracted earlier from the same
|
||||
// archive, which is what a tar hard link entry stands for.
|
||||
func extractArchivedHardLink(destRoot, destPath string, h *tar.Header, extracted map[string]os.FileInfo) error {
|
||||
targetPath, err := safeExtractPath(destRoot, h.Linkname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notExtracted := fmt.Errorf("hard link %q points to %q, which is not a file earlier in the archive", h.Name, h.Linkname)
|
||||
written, ok := extracted[targetPath]
|
||||
if !ok {
|
||||
return notExtracted
|
||||
}
|
||||
src, err := os.Open(targetPath)
|
||||
if err != nil {
|
||||
logger.Debugf("unarchive: open hard link target: %v", err)
|
||||
return fmt.Errorf("failed to write file from archive entry")
|
||||
}
|
||||
// A later entry may have replaced the target, e.g. with a symlink to another file.
|
||||
info, err := src.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() || !os.SameFile(info, written) {
|
||||
_ = src.Close()
|
||||
return notExtracted
|
||||
}
|
||||
// The entry may name its own target, directly (GNU tar does this for a file archived
|
||||
// twice). Writing it would truncate the file it copies from. Symlinks at destPath
|
||||
// are replaced by extractArchivedRegularFile instead of being left in place.
|
||||
if dest, err := os.Lstat(destPath); err == nil && dest.Mode().IsRegular() && os.SameFile(dest, info) {
|
||||
return src.Close()
|
||||
}
|
||||
return extractArchivedRegularFile(destPath, h.FileInfo().Mode(), h.ModTime, src)
|
||||
}
|
||||
|
||||
func extractZipWithEncoding(archivePath, destDir, charset string) error {
|
||||
// recordExtractedFile remembers the regular file written at destPath for later hard links.
|
||||
func recordExtractedFile(extracted map[string]os.FileInfo, destPath string) {
|
||||
if info, err := os.Lstat(destPath); err == nil && info.Mode().IsRegular() {
|
||||
extracted[destPath] = info
|
||||
} else {
|
||||
delete(extracted, destPath)
|
||||
}
|
||||
}
|
||||
|
||||
func extractZip(archivePath, destDir string) error {
|
||||
return extractZipWithEncoding(archivePath, destDir, "", nil)
|
||||
}
|
||||
|
||||
func extractZipWithEncoding(archivePath, destDir, charset string, policy *extractAccessPolicy) error {
|
||||
r, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1199,6 +1375,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 +1443,74 @@ 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
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
// Regular files written so far, for hard links to point at.
|
||||
extracted := make(map[string]os.FileInfo)
|
||||
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:
|
||||
mode := h.FileInfo().Mode()
|
||||
if err = extractArchivedDir(destPath, mode, h.ModTime); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
mode := h.FileInfo().Mode()
|
||||
if err = extractArchivedRegularFile(destPath, mode, h.ModTime, io.NopCloser(tr)); err != nil {
|
||||
return err
|
||||
}
|
||||
recordExtractedFile(extracted, destPath)
|
||||
case tar.TypeLink:
|
||||
if err = extractArchivedHardLink(destDir, destPath, h, extracted); err != nil {
|
||||
return err
|
||||
}
|
||||
recordExtractedFile(extracted, destPath)
|
||||
case tar.TypeSymlink:
|
||||
if err = extractArchivedSymlink(destDir, destPath, h.Linkname, h.ModTime); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if h.Size > 0 {
|
||||
if _, err = io.CopyN(io.Discard, tr, h.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -1272,25 +1532,21 @@ 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
|
||||
}
|
||||
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
mode := h.FileInfo().Mode()
|
||||
if err = extractArchivedDir(destPath, mode, h.ModTime); err != nil {
|
||||
if err = preflightExtractEntry(policy, h.Name, destPath, true, false); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
mode := h.FileInfo().Mode()
|
||||
if err = extractArchivedRegularFile(destPath, mode, h.ModTime, io.NopCloser(tr)); err != nil {
|
||||
case tar.TypeReg, tar.TypeLink:
|
||||
if err = preflightExtractEntry(policy, h.Name, destPath, false, false); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeSymlink:
|
||||
if err = extractArchivedSymlink(destDir, destPath, h.Linkname, h.ModTime); err != nil {
|
||||
if err = preflightExtractEntry(policy, h.Name, destPath, false, true); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -322,7 +324,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")
|
||||
@@ -343,3 +345,215 @@ func TestExtractZipFilenameEncoding(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTarGz_hardLinks(t *testing.T) {
|
||||
archivePath := filepath.Join(t.TempDir(), "links.tar.gz")
|
||||
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "proj/a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(tw, "same"); err != nil {
|
||||
return err
|
||||
}
|
||||
return tw.WriteHeader(&tar.Header{Name: "proj/sub/b.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "proj/a.txt"})
|
||||
})
|
||||
destDir := t.TempDir()
|
||||
if err := extractTarGz(archivePath, destDir, nil); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(destDir, "proj", "sub", "b.txt"))
|
||||
if err != nil || string(got) != "same" {
|
||||
t.Fatalf("hard link entry: got %q, %v", got, err)
|
||||
}
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "missing.tar.gz")
|
||||
writeTestTarGz(t, missing, func(tw *tar.Writer) error {
|
||||
return tw.WriteHeader(&tar.Header{Name: "b.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
|
||||
})
|
||||
if err := extractTarGz(missing, t.TempDir(), nil); err == nil {
|
||||
t.Fatal("expected an error for a hard link to a file that is not in the archive")
|
||||
}
|
||||
|
||||
// A symlink entry replaces the extracted file before the hard link names it.
|
||||
replaced := filepath.Join(t.TempDir(), "replaced.tar.gz")
|
||||
writeTestTarGz(t, replaced, func(tw *tar.Writer) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(tw, "mine"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "private/secret.txt"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tw.WriteHeader(&tar.Header{Name: "b.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
|
||||
})
|
||||
replacedDest := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(replacedDest, "private"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(replacedDest, "private", "secret.txt"), []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := extractTarGz(replaced, replacedDest, nil); err == nil {
|
||||
t.Fatal("expected an error for a hard link whose target was replaced by a symlink")
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(replacedDest, "b.txt")); err == nil {
|
||||
t.Fatalf("hard link entry copied the symlink target: %q", got)
|
||||
}
|
||||
|
||||
// A hard link names its own target, as GNU tar writes for a file archived twice,
|
||||
// or names it through a symlink.
|
||||
self := filepath.Join(t.TempDir(), "self.tar.gz")
|
||||
writeTestTarGz(t, self, func(tw *tar.Writer) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(tw, "same"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "c.txt", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "a.txt"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tw.WriteHeader(&tar.Header{Name: "c.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
|
||||
})
|
||||
selfDest := t.TempDir()
|
||||
if err := extractTarGz(self, selfDest, nil); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(selfDest, "a.txt")); err != nil || string(got) != "same" {
|
||||
t.Fatalf("hard link to itself: got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTarGz_symlinkDestination(t *testing.T) {
|
||||
t.Run("pre-existing symlink at destination", func(t *testing.T) {
|
||||
destDir := t.TempDir()
|
||||
victim := filepath.Join(destDir, "victim.txt")
|
||||
if err := os.WriteFile(victim, []byte("untouched"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outPath := filepath.Join(destDir, "out.txt")
|
||||
if err := os.Symlink("victim.txt", outPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archivePath := filepath.Join(t.TempDir(), "out.tar.gz")
|
||||
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "out.txt", Mode: 0o644, Size: 7, Typeflag: tar.TypeReg}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := io.WriteString(tw, "payload")
|
||||
return err
|
||||
})
|
||||
if err := extractTarGz(archivePath, destDir, nil); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(outPath)
|
||||
if err != nil || string(got) != "payload" {
|
||||
t.Fatalf("out.txt: got %q, %v", got, err)
|
||||
}
|
||||
victimData, err := os.ReadFile(victim)
|
||||
if err != nil || string(victimData) != "untouched" {
|
||||
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
|
||||
}
|
||||
info, err := os.Lstat(outPath)
|
||||
if err != nil || info.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatalf("out.txt should be a regular file, mode=%v err=%v", info.Mode(), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("symlink entry then regular file at same path", func(t *testing.T) {
|
||||
destDir := t.TempDir()
|
||||
other := filepath.Join(destDir, "other.txt")
|
||||
if err := os.WriteFile(other, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archivePath := filepath.Join(t.TempDir(), "link_then_reg.tar.gz")
|
||||
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "link", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "other.txt"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "link", Mode: 0o644, Size: 7, Typeflag: tar.TypeReg}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := io.WriteString(tw, "written")
|
||||
return err
|
||||
})
|
||||
if err := extractTarGz(archivePath, destDir, nil); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
linkPath := filepath.Join(destDir, "link")
|
||||
got, err := os.ReadFile(linkPath)
|
||||
if err != nil || string(got) != "written" {
|
||||
t.Fatalf("link: got %q, %v", got, err)
|
||||
}
|
||||
otherData, err := os.ReadFile(other)
|
||||
if err != nil || string(otherData) != "keep" {
|
||||
t.Fatalf("other.txt should be unchanged: %q, %v", otherData, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hard link onto symlink to source", func(t *testing.T) {
|
||||
destDir := t.TempDir()
|
||||
archivePath := filepath.Join(t.TempDir(), "hardlink_symlink_dest.tar.gz")
|
||||
writeTestTarGz(t, archivePath, func(tw *tar.Writer) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o644, Size: 4, Typeflag: tar.TypeReg}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(tw, "same"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "b.txt", Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "a.txt"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tw.WriteHeader(&tar.Header{Name: "c.txt", Mode: 0o644, Typeflag: tar.TypeLink, Linkname: "a.txt"})
|
||||
})
|
||||
if err := extractTarGz(archivePath, destDir, nil); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
cPath := filepath.Join(destDir, "c.txt")
|
||||
got, err := os.ReadFile(cPath)
|
||||
if err != nil || string(got) != "same" {
|
||||
t.Fatalf("c.txt: got %q, %v", got, err)
|
||||
}
|
||||
info, err := os.Lstat(cPath)
|
||||
if err != nil || info.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatalf("c.txt should be a regular file")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractZip_symlinkDestination(t *testing.T) {
|
||||
destDir := t.TempDir()
|
||||
victim := filepath.Join(destDir, "victim.txt")
|
||||
if err := os.WriteFile(victim, []byte("untouched"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outPath := filepath.Join(destDir, "out.txt")
|
||||
if err := os.Symlink("victim.txt", outPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "out.zip")
|
||||
writeTestZip(t, zipPath, func(zw *zip.Writer) error {
|
||||
w, err := zw.Create("out.txt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write([]byte("payload"))
|
||||
return err
|
||||
})
|
||||
if err := extractZip(zipPath, destDir); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(outPath)
|
||||
if err != nil || string(got) != "payload" {
|
||||
t.Fatalf("out.txt: got %q, %v", got, err)
|
||||
}
|
||||
victimData, err := os.ReadFile(victim)
|
||||
if err != nil || string(victimData) != "untouched" {
|
||||
t.Fatalf("victim.txt should be unchanged: %q, %v", victimData, err)
|
||||
}
|
||||
}
|
||||
@@ -594,7 +594,7 @@ func applyNamedApiTokenGlobalCaps(user *users.User, tokenName string) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !users.HasAnyGlobalPermission(stored.Permissions) {
|
||||
if users.IsMinimalApiToken(stored) {
|
||||
return
|
||||
}
|
||||
user.Permissions = users.IntersectGlobalPermissions(user.Permissions, stored.Permissions)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -197,9 +197,11 @@ func TestApplyNamedApiTokenGlobalCaps(t *testing.T) {
|
||||
|
||||
t.Run("custom api token caps globals", func(t *testing.T) {
|
||||
user := *owner
|
||||
customized := false
|
||||
user.Tokens = map[string]users.AuthToken{
|
||||
"customized": {
|
||||
Name: "customized",
|
||||
Minimal: &customized,
|
||||
Permissions: users.Permissions{
|
||||
Admin: false,
|
||||
Api: true,
|
||||
@@ -216,4 +218,20 @@ func TestApplyNamedApiTokenGlobalCaps(t *testing.T) {
|
||||
t.Fatalf("expected api and share, got %#v", user.Permissions)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("customized zero global caps", func(t *testing.T) {
|
||||
user := *owner
|
||||
customized := false
|
||||
user.Tokens = map[string]users.AuthToken{
|
||||
"locked-down": {
|
||||
Name: "locked-down",
|
||||
Minimal: &customized,
|
||||
Permissions: users.Permissions{},
|
||||
},
|
||||
}
|
||||
applyNamedApiTokenGlobalCaps(&user, "locked-down")
|
||||
if user.Permissions.Admin || user.Permissions.Api || user.Permissions.Share {
|
||||
t.Fatalf("customized empty caps should clear globals, got %#v", user.Permissions)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
libErrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/quota"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
libErrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/quota"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
@@ -127,7 +127,7 @@ var (
|
||||
// Per-route-class token buckets (keys: IP or username). Expired entries are dropped by go-cache.
|
||||
var (
|
||||
authRateLimitCredentialByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
authRateLimitCredentialByUsername = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
authRateLimitCredentialByUsername = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
authRateLimitModerateByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
authRateLimitOIDCByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
authRateLimitAuthenticatedByUser = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
@@ -256,14 +256,24 @@ func SetUserInResponseWriter(w http.ResponseWriter, user *users.User) {
|
||||
func GetRemoteIP(r *http.Request) string {
|
||||
cfg := &settings.Config
|
||||
|
||||
// Forwarded client headers are only honored when the socket peer is a
|
||||
// configured trusted proxy; otherwise a direct client could spoof its IP.
|
||||
trustedPeer := cfg.Http.TrustProxyHeaders && settings.IsTrustedProxyPeer(r)
|
||||
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if cfg.Http.TrustProxyHeaders && xff != "" {
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[0])
|
||||
if trustedPeer && xff != "" {
|
||||
// Without http.trustedProxies (Unix-socket listener) this returns the
|
||||
// rightmost XFF entry, the client as recorded by the proxy. With trusted proxies
|
||||
// configured, the walk skips proxy hops so multi-hop deployments
|
||||
// (CDN -> reverse proxy -> filebrowser) resolve the real client IP —
|
||||
// keeping per-IP rate limits and auth lockouts per client, not per edge.
|
||||
if ip := settings.TrustedProxyClientIP(xff); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
xri := r.Header.Get("X-Real-IP")
|
||||
if cfg.Http.TrustProxyHeaders && xri != "" {
|
||||
if trustedPeer && xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,6 +8,132 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestGetRemoteIPUsesRightmostForwardedFor(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
// The socket peer must be a trusted proxy for forwarded headers to be honored.
|
||||
settings.Config.Http.TrustedProxies = []string{"10.0.0.0/8"}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// With http.trustedProxies set, GetRemoteIP walks the XFF chain past trusted
|
||||
// proxy hops so multi-hop deployments (CDN -> proxy -> filebrowser) resolve the
|
||||
// real client IP instead of collapsing everyone onto the shared edge address.
|
||||
func TestGetRemoteIPTrustedProxies(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
settings.Config.Http.TrustedProxies = []string{"198.51.100.0/24", "10.0.0.0/8"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
xff string
|
||||
want string
|
||||
}{
|
||||
{"multi-hop resolves real client", "203.0.113.50, 198.51.100.7", "203.0.113.50"},
|
||||
{"three hops", "192.0.2.10, 203.0.113.50, 198.51.100.7", "203.0.113.50"},
|
||||
// Client-supplied XFF entries stay untrusted and cannot spoof identity.
|
||||
{"spoofed prefix ignored", "1.2.3.4, 203.0.113.50, 198.51.100.7", "203.0.113.50"},
|
||||
{"empty entries skipped", "203.0.113.50, , 198.51.100.7", "203.0.113.50"},
|
||||
// Every hop trusted: leftmost is closest to the client.
|
||||
{"all entries trusted", "198.51.100.7, 198.51.100.8", "198.51.100.7"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com/", nil)
|
||||
req.RemoteAddr = "10.0.0.1:1234"
|
||||
req.Header.Set("X-Forwarded-For", tt.xff)
|
||||
if got := GetRemoteIP(req); got != tt.want {
|
||||
t.Fatalf("GetRemoteIP = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Bare IPs (not just CIDRs) are accepted in http.trustedProxies.
|
||||
func TestGetRemoteIPTrustedProxiesBareIP(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
settings.Config.Http.TrustedProxies = []string{"198.51.100.7", "10.0.0.1"}
|
||||
|
||||
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 != "203.0.113.50" {
|
||||
t.Fatalf("GetRemoteIP = %q, want 203.0.113.50", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Without trustProxyHeaders the XFF chain is ignored entirely.
|
||||
func TestGetRemoteIPIgnoresForwardedForWhenUntrusted(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = false
|
||||
settings.Config.Http.TrustedProxies = []string{"198.51.100.0/24"}
|
||||
|
||||
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 != "10.0.0.1" {
|
||||
t.Fatalf("GetRemoteIP = %q, want 10.0.0.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A direct client that is not a trusted proxy cannot spoof its address with
|
||||
// forwarded headers, even when trustProxyHeaders is enabled.
|
||||
func TestGetRemoteIPIgnoresForwardedHeadersFromUntrustedPeer(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
settings.Config.Http.TrustedProxies = []string{"198.51.100.0/24"}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/", nil)
|
||||
req.RemoteAddr = "192.0.2.99:1234"
|
||||
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||
req.Header.Set("X-Real-IP", "5.6.7.8")
|
||||
if got := GetRemoteIP(req); got != "192.0.2.99" {
|
||||
t.Fatalf("GetRemoteIP = %q, want 192.0.2.99", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no trusted proxies configured, forwarded headers are never honored.
|
||||
func TestGetRemoteIPIgnoresForwardedHeadersWithoutTrustedProxies(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
settings.Config.Http.TrustedProxies = nil
|
||||
|
||||
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")
|
||||
if got := GetRemoteIP(req); got != "10.0.0.1" {
|
||||
t.Fatalf("GetRemoteIP = %q, want 10.0.0.1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An IPv4-mapped IPv6 trusted hop in the XFF chain must still be skipped.
|
||||
func TestGetRemoteIPMappedTrustedHop(t *testing.T) {
|
||||
orig := settings.Config.Http
|
||||
t.Cleanup(func() { settings.Config.Http = orig })
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
settings.Config.Http.TrustedProxies = []string{"::ffff:198.51.100.0/120"}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://example.com/", nil)
|
||||
req.RemoteAddr = "198.51.100.1:1234"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.50, ::ffff:198.51.100.7")
|
||||
if got := GetRemoteIP(req); got != "203.0.113.50" {
|
||||
t.Fatalf("GetRemoteIP = %q, want 203.0.113.50", 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,10 @@ 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/indexing/iteminfo"
|
||||
"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 +36,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 +48,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 +117,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 +161,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 {
|
||||
@@ -320,3 +314,25 @@ func renderQuotaError(w http.ResponseWriter, r *http.Request, err error) (int, e
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func prepareDeleteQuotaAdjustments(d *Context, sourceName, fullIndexPath string, fileInfo *iteminfo.ExtendedFileInfo) state.DeleteAdjustments {
|
||||
sourceInfo, ok := settings.Config.Server.NameToSource[sourceName]
|
||||
if !ok || fileInfo == nil {
|
||||
return state.DeleteAdjustments{}
|
||||
}
|
||||
principal := quota.PrincipalForUpload(d.User, d.Share.UserID, d.Share.Hash)
|
||||
isDir := fileInfo.Type == "directory"
|
||||
var deleteBytes int64
|
||||
if isDir {
|
||||
deleteBytes, _ = state.IndexedPathBytes(sourceName, fullIndexPath, true)
|
||||
} else {
|
||||
deleteBytes = fileInfo.Size
|
||||
}
|
||||
return state.PrepareAccountedDelete(principal, sourceName, sourceInfo.Path, fullIndexPath, d.Share.Hash, isDir, deleteBytes)
|
||||
}
|
||||
|
||||
func applyDeleteQuotaAdjustments(adj state.DeleteAdjustments) {
|
||||
if err := state.ApplyAccountedDelete(adj); err != nil {
|
||||
logger.Warningf("quota adjust after delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -270,30 +270,25 @@ 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)
|
||||
|
||||
principal := quota.PrincipalForUpload(d.User, d.Share.UserID, d.Share.Hash)
|
||||
sourceInfo, ok := settings.Config.Server.NameToSource[source]
|
||||
if !ok {
|
||||
if _, ok := settings.Config.Server.NameToSource[source]; !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
var deleteBytes int64
|
||||
if fileInfo.Type == "directory" {
|
||||
deleteBytes, _ = state.IndexedPathBytes(source, fileInfo.Path, true)
|
||||
} else {
|
||||
deleteBytes = fileInfo.Size
|
||||
}
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
adjustments := prepareDeleteQuotaAdjustments(d, source, fullIndexPath, fileInfo)
|
||||
err = files.DeleteFiles(source, userScope, fullIndexPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
return ErrToStatus(err), err
|
||||
}
|
||||
if deleteBytes > 0 {
|
||||
if adjErr := state.ApplyAccountedUsageDelta(principal, source, sourceInfo.Path, fileInfo.Path, d.Share.Hash, -deleteBytes); adjErr != nil {
|
||||
logger.Warningf("quota adjust after delete failed: %v", adjErr)
|
||||
}
|
||||
}
|
||||
applyDeleteQuotaAdjustments(adjustments)
|
||||
activity.RecordDelete(r, toActor(d), source, path)
|
||||
return http.StatusOK, nil
|
||||
|
||||
@@ -312,12 +307,24 @@ 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)
|
||||
sourceName := d.Share.GetSourceName()
|
||||
adjustments := prepareDeleteQuotaAdjustments(d, sourceName, fullIndexPath, fileInfo)
|
||||
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")
|
||||
}
|
||||
applyDeleteQuotaAdjustments(adjustments)
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
activity.RecordDelete(r, toActor(d), d.Share.GetSourceName(), fileInfo.Path)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
@@ -390,10 +397,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 +485,12 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
|
||||
// Delete the file/directory
|
||||
err = files.DeleteFiles(sourceName, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
// indexPath is scope-relative; rebuild full index paths for the bound and target.
|
||||
boundIndexPath := utils.JoinScopedIndexPath(userScope, withoutUserScope)
|
||||
fullIndexPath := utils.JoinScopedIndexPath(userScope, indexPath)
|
||||
|
||||
adjustments := prepareDeleteQuotaAdjustments(d, sourceName, fullIndexPath, fileInfo)
|
||||
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{
|
||||
@@ -487,8 +500,10 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
})
|
||||
continue
|
||||
}
|
||||
applyDeleteQuotaAdjustments(adjustments)
|
||||
// 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 +516,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 +569,9 @@ 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)
|
||||
adjustments := prepareDeleteQuotaAdjustments(d, item.Source, fullIndexPath, fileInfo)
|
||||
err = files.DeleteFiles(item.Source, userScope, fullIndexPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -563,6 +580,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
|
||||
})
|
||||
continue
|
||||
}
|
||||
applyDeleteQuotaAdjustments(adjustments)
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
}
|
||||
// Success — echo the client path in the response
|
||||
@@ -574,6 +592,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 +601,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 +765,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 +817,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 +878,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 +1038,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 +1151,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 +1258,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 +1303,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 +1506,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 +1530,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 +1549,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 +1924,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
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Use a database-backed index because deletion refreshes and removes metadata.
|
||||
func setupDeleteQuotaTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
sourcePath := setupPutQuotaTest(t)
|
||||
settings.Config.Http.BaseURL = "/"
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
oldDB := indexing.GetIndexDB()
|
||||
settings.Config.Server.CacheDir = t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "sql"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, _, err := dbsql.NewIndexDB("delete_quota", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
indexing.SetIndexDBForTesting(oldDB)
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
source := settings.Config.Server.NameToSource["Downloads"]
|
||||
source.Config.UseLogicalSize = true
|
||||
source.Config.ResolvedRules.IndexingDisabled = true
|
||||
indexing.Initialize(source, true, false)
|
||||
indexing.GetIndex("Downloads").Config.ResolvedRules.IndexingDisabled = false
|
||||
return sourcePath
|
||||
}
|
||||
|
||||
// seedNestedQuotaFixture writes files under scope, indexes them, and creates accounted
|
||||
// quotas on public/nested (5 bytes used) and sibling (7 bytes used).
|
||||
func seedNestedQuotaFixture(t *testing.T, sourcePath, scope string, user *users.User) (nested, sibling *quota.FolderQuota) {
|
||||
t.Helper()
|
||||
for path, content := range map[string]string{"public/direct": "123", "public/nested/file": "12345", "sibling/file": "1234567"} {
|
||||
full := filepath.Join(sourcePath, scope, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := indexing.GetIndex("Downloads").RefreshDirectory("/", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nested, err := state.CreateFolderQuota(sourcePath, utils.JoinPathAsUnix(scope, "/public/nested"), user.ID, 100, "accounted")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sibling, err = state.CreateFolderQuota(sourcePath, utils.JoinPathAsUnix(scope, "/sibling"), user.ID, 100, "accounted")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, size := range map[string]int64{"/public/nested/file": 5, "/sibling/file": 7} {
|
||||
if err = state.ApplyAccountedUsageDelta(user, "Downloads", sourcePath, utils.JoinPathAsUnix(scope, path), "", size); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return nested, sibling
|
||||
}
|
||||
|
||||
// The API path is relative to the user's scope while quota paths and the index are
|
||||
// source-relative, so the scoped case checks that the delete joins the scope.
|
||||
func TestResourceDeleteNestedQuota(t *testing.T) {
|
||||
for _, scope := range []string{"/", "/scoped"} {
|
||||
t.Run(scope, func(t *testing.T) {
|
||||
sourcePath := setupDeleteQuotaTest(t)
|
||||
if preview.GetService() == nil {
|
||||
if err := preview.StartPreviewGenerator(1, t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
user, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user.BackendScopes[0].Scope = scope
|
||||
nested, sibling := seedNestedQuotaFixture(t, sourcePath, scope, &user)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/resources?source=Downloads&path=/public", nil)
|
||||
status, err := resourceDeleteHandler(httptest.NewRecorder(), req, &requestContext{User: &user})
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("delete: status=%d err=%v", status, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sourcePath, scope, "public")); !os.IsNotExist(err) {
|
||||
t.Fatalf("ancestor still exists: %v", err)
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(nested.ID); used != 0 {
|
||||
t.Fatalf("nested usage=%d, want 0", used)
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(sibling.ID); used != 7 {
|
||||
t.Fatalf("sibling usage=%d, want 7", used)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceBulkDeleteNestedQuota(t *testing.T) {
|
||||
for _, scope := range []string{"/", "/scoped"} {
|
||||
t.Run(scope, func(t *testing.T) {
|
||||
sourcePath := setupDeleteQuotaTest(t)
|
||||
if preview.GetService() == nil {
|
||||
if err := preview.StartPreviewGenerator(1, t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
user, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user.BackendScopes[0].Scope = scope
|
||||
nested, sibling := seedNestedQuotaFixture(t, sourcePath, scope, &user)
|
||||
body, err := json.Marshal([]BulkDeleteItem{{Source: "Downloads", Path: "/public"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/resources/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := ResourceBulkDeleteHandler(rec, req, &requestContext{User: &user})
|
||||
if err != nil || status != http.StatusOK {
|
||||
t.Fatalf("bulk delete: status=%d err=%v body=%s", status, err, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sourcePath, scope, "public")); !os.IsNotExist(err) {
|
||||
t.Fatalf("ancestor still exists: %v", err)
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(nested.ID); used != 0 {
|
||||
t.Fatalf("nested usage=%d, want 0", used)
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(sibling.ID); used != 7 {
|
||||
t.Fatalf("sibling usage=%d, want 7", used)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceBulkDeletePartialFailureKeepsQuota(t *testing.T) {
|
||||
sourcePath := setupDeleteQuotaTest(t)
|
||||
user, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nested, sibling := seedNestedQuotaFixture(t, sourcePath, "/", &user)
|
||||
body, err := json.Marshal([]BulkDeleteItem{
|
||||
{Source: "Downloads", Path: "/no-such-path"},
|
||||
{Source: "Downloads", Path: "/public"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/resources/bulk", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := ResourceBulkDeleteHandler(rec, req, &requestContext{User: &user})
|
||||
if err != nil || status != http.StatusMultiStatus {
|
||||
t.Fatalf("bulk delete: status=%d err=%v", status, err)
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(nested.ID); used != 0 {
|
||||
t.Fatalf("nested usage=%d, want 0 after successful item", used)
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(sibling.ID); used != 7 {
|
||||
t.Fatalf("sibling usage=%d, want 7", used)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
_ "net/http/pprof"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/activation"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/events"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
@@ -40,6 +40,8 @@ var (
|
||||
func initRuntime(deps Deps, assets fs.FS) {
|
||||
runtimeDeps = deps
|
||||
assetFs = assets
|
||||
// Make the generated YAML's `secret:` markers available to the settings redactor.
|
||||
initSettingsSecretMarkers()
|
||||
}
|
||||
|
||||
// Embed the files in the frontend/dist directory
|
||||
@@ -91,8 +93,9 @@ func StartHttp(ctx context.Context, deps Deps, shutdownComplete chan struct{}) {
|
||||
configureHTTPRouter(router, api, publicRoutes, publicApi)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: settings.HTTPListenAddr(settings.Config.Http.ListenAddress, settings.Config.Http.Port),
|
||||
Handler: muxWithMiddleware(router),
|
||||
Addr: settings.HTTPListenAddr(settings.Config.Http.ListenAddress, settings.Config.Http.Port),
|
||||
Handler: muxWithMiddleware(router),
|
||||
ConnContext: settings.AttachConnContext,
|
||||
}
|
||||
listenAddress := settings.Config.Http.ListenAddress
|
||||
go func() {
|
||||
@@ -103,7 +106,7 @@ func StartHttp(ctx context.Context, deps Deps, shutdownComplete chan struct{}) {
|
||||
if err = os.Remove(settings.Config.Http.Socket); err != nil && !os.IsNotExist(err) {
|
||||
logger.Fatalf("Could not remove existing socket: %v", err)
|
||||
}
|
||||
listener, err = net.Listen("unix", settings.Config.Http.Socket)
|
||||
listener, err = settings.ListenUnixFilesystem(settings.Config.Http.Socket)
|
||||
if err != nil {
|
||||
logger.Fatalf("Could not listen on unix socket: %v", err)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,19 @@ func readSettingsPatchBody(w http.ResponseWriter, r *http.Request) ([]byte, erro
|
||||
return patchJSON, nil
|
||||
}
|
||||
|
||||
// initSettingsSecretMarkers points the settings package at the generated YAML shipped in the
|
||||
// running binary's asset filesystem, so it can discover the `secret:` comment markers that
|
||||
// decide which config fields are redacted. The Go sources are absent in a deployed binary, so
|
||||
// this is the marker source that matters in production.
|
||||
func initSettingsSecretMarkers() {
|
||||
if settings.SecretHintYaml != "" {
|
||||
return
|
||||
}
|
||||
if embedded, err := readEmbeddedConfigYaml(); err == nil {
|
||||
settings.SecretHintYaml = embedded
|
||||
}
|
||||
}
|
||||
|
||||
// settingsGetHandler retrieves the current system settings.
|
||||
// @Summary Get system settings
|
||||
// @Description Returns the current configuration settings for signup, user directories, rules, frontend.
|
||||
@@ -44,6 +57,9 @@ func readSettingsPatchBody(w http.ResponseWriter, r *http.Request) ([]byte, erro
|
||||
// @Router /api/settings [get]
|
||||
func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
property := r.URL.Query().Get("property")
|
||||
if property == "auth" {
|
||||
return RenderJSON(w, r, settings.RedactAuthSecrets(settings.Config.Auth))
|
||||
}
|
||||
if property != "" {
|
||||
// get property by name
|
||||
switch property {
|
||||
@@ -51,8 +67,6 @@ func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
|
||||
return RenderJSON(w, r, state.GetDefaultUserDefaults())
|
||||
case "frontend":
|
||||
return RenderJSON(w, r, settings.Config.Frontend)
|
||||
case "auth":
|
||||
return RenderJSON(w, r, settings.Config.Auth)
|
||||
case "server":
|
||||
return RenderJSON(w, r, settings.Config.Server)
|
||||
case "sources":
|
||||
@@ -61,7 +75,26 @@ func settingsGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
|
||||
return http.StatusNotFound, nil
|
||||
}
|
||||
}
|
||||
return RenderJSON(w, r, &settings.Config)
|
||||
// Redact secrets (auth.key, totpSecret, adminPassword, OIDC clientSecret, LDAP/recaptcha
|
||||
// secrets, ...) so the admin response never echoes plaintext credentials (GHSA-892f-9h5p-mfc8).
|
||||
// RedactedSettingsCopy returns a copy: the live config keeps the real secrets, so auth and
|
||||
// the OIDC/LDAP/OnlyOffice integrations keep working after this request.
|
||||
return RenderJSON(w, r, settings.RedactedSettingsCopy(&settings.Config))
|
||||
}
|
||||
|
||||
// readEmbeddedConfigYaml returns the config.generated.yaml shipped in the binary's asset
|
||||
// filesystem. It is the comment/secret-marker source for both the YAML export and the settings
|
||||
// JSON redactor.
|
||||
func readEmbeddedConfigYaml() (string, error) {
|
||||
path := "config.generated.yaml"
|
||||
if settings.Env.EmbeddedFs {
|
||||
path = "embed/config.generated.yaml"
|
||||
}
|
||||
content, err := fs.ReadFile(assetFs, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// settingsConfigHandler returns the current system settings as YAML.
|
||||
@@ -85,22 +118,12 @@ func settingsConfigHandler(w http.ResponseWriter, r *http.Request, d *Context) (
|
||||
var err error
|
||||
var yamlConfig string
|
||||
|
||||
// Try to read the generated YAML file for comments (should always exist)
|
||||
var embeddedYaml []byte
|
||||
var readErr error
|
||||
|
||||
if settings.Env.EmbeddedFs {
|
||||
embeddedYaml, readErr = fs.ReadFile(assetFs, "embed/config.generated.yaml")
|
||||
} else {
|
||||
embeddedYaml, readErr = fs.ReadFile(assetFs, "config.generated.yaml")
|
||||
if readErr != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error reading generated YAML: %v", readErr)
|
||||
}
|
||||
}
|
||||
// Comment/secret-marker source, shared with settingsGetHandler's redactor.
|
||||
embeddedYaml, _ := readEmbeddedConfigYaml()
|
||||
|
||||
// If we successfully read the generated YAML, use it as the comment source
|
||||
if readErr == nil && len(embeddedYaml) > 0 {
|
||||
yamlConfig, err = settings.GenerateConfigYamlWithEmbedded(&settings.Config, showComments, showFull, false, string(embeddedYaml))
|
||||
if embeddedYaml != "" {
|
||||
yamlConfig, err = settings.GenerateConfigYamlWithEmbedded(&settings.Config, showComments, showFull, false, embeddedYaml)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error generating YAML: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
const settingsRedactSentinel = "SENTINEL-SETTINGS-SECRET"
|
||||
|
||||
// TestSettingsGetHandler_redactsSecrets is the end-to-end regression guard for
|
||||
// GHSA-892f-9h5p-mfc8: GET /api/settings (and ?property=auth) must not return cleartext secrets.
|
||||
func TestSettingsGetHandler_redactsSecrets(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
prev := settings.Config
|
||||
t.Cleanup(func() { settings.Config = prev })
|
||||
settings.Config.Auth.Key = settingsRedactSentinel
|
||||
settings.Config.Auth.TotpSecret = settingsRedactSentinel
|
||||
settings.Config.Auth.Methods.PasswordAuth.AdminPassword = settingsRedactSentinel
|
||||
settings.Config.Auth.Methods.PasswordAuth.Recaptcha.Secret = settingsRedactSentinel
|
||||
settings.Config.Auth.Methods.OidcAuth.ClientSecret = settingsRedactSentinel
|
||||
settings.Config.Auth.Methods.LdapAuth.UserPassword = settingsRedactSentinel
|
||||
settings.Config.Auth.Methods.JwtAuth.Secret = settingsRedactSentinel
|
||||
settings.Config.Integrations.OnlyOffice.Secret = settingsRedactSentinel
|
||||
|
||||
admin := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "settings_redact_admin",
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, path := range []string{"/api/settings", "/api/settings?property=auth"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, err := settingsGetHandler(rec, req, &Context{User: admin})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status=%d err=%v want 200", status, err)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, settingsRedactSentinel) {
|
||||
t.Fatalf("response for %s leaked a plaintext secret: %s", path, body)
|
||||
}
|
||||
if !json.Valid([]byte(body)) {
|
||||
t.Fatalf("response for %s is not valid JSON: %s", path, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsGetHandler_doesNotMutateLiveConfig is the regression guard for the live-config
|
||||
// mutation: the full settings request must redact only its response, never settings.Config,
|
||||
// otherwise JWT signing and the integrations break until the process restarts.
|
||||
func TestSettingsGetHandler_doesNotMutateLiveConfig(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
prev := settings.Config
|
||||
t.Cleanup(func() { settings.Config = prev })
|
||||
settings.Config.Auth.Key = settingsRedactSentinel
|
||||
|
||||
admin := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "settings_redact_admin3",
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/settings", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := settingsGetHandler(rec, req, &Context{User: admin})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status=%d err=%v want 200", status, err)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), settingsRedactSentinel) {
|
||||
t.Fatal("response leaked a plaintext secret")
|
||||
}
|
||||
if settings.Config.Auth.Key != settingsRedactSentinel {
|
||||
t.Fatalf("live config auth key was mutated by the settings response: %q", settings.Config.Auth.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsGetHandler_redactsAuthKeyOnlyWherePresent confirms non-secret auth fields still
|
||||
// round-trip so the response stays usable for the UI.
|
||||
func TestSettingsGetHandler_keepsNonSecretAuthFields(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
prev := settings.Config
|
||||
t.Cleanup(func() { settings.Config = prev })
|
||||
settings.Config.Auth.Key = settingsRedactSentinel
|
||||
settings.Config.Auth.TokenExpirationHours = 7
|
||||
|
||||
admin := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "settings_redact_admin2",
|
||||
Permissions: users.Permissions{Admin: true},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/settings?property=auth", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
status, err := settingsGetHandler(rec, req, &Context{User: admin})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status=%d err=%v want 200", status, err)
|
||||
}
|
||||
|
||||
var got settings.Auth
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal auth: %v", err)
|
||||
}
|
||||
if got.TokenExpirationHours != 7 {
|
||||
t.Fatalf("tokenExpirationHours=%d want 7", got.TokenExpirationHours)
|
||||
}
|
||||
if got.Key == settingsRedactSentinel {
|
||||
t.Fatal("auth key was returned in cleartext")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -72,13 +73,15 @@ func normalizeViewGrantPath(p string) string {
|
||||
|
||||
// shareRelativeDisplayName returns the client-facing filename for a path within a share.
|
||||
// Single-file shares use file=/ at the share root; the real name comes from the share path.
|
||||
// Both paths are slash-separated, so use path.Base: on Windows filepath.Base("/")
|
||||
// returns "\", which would skip the share-name fallback below.
|
||||
func shareRelativeDisplayName(d *Context, shareRelativePath string) string {
|
||||
name := filepath.Base(normalizeViewGrantPath(shareRelativePath))
|
||||
name := path.Base(normalizeViewGrantPath(shareRelativePath))
|
||||
if name != "" && name != "." && name != "/" {
|
||||
return name
|
||||
}
|
||||
if d != nil && d.Share.Hash != "" {
|
||||
if shareName := filepath.Base(d.Share.Path); shareName != "" && shareName != "." && shareName != "/" {
|
||||
if shareName := path.Base(normalizeViewGrantPath(d.Share.Path)); shareName != "" && shareName != "." && shareName != "/" {
|
||||
return shareName
|
||||
}
|
||||
}
|
||||
@@ -232,7 +235,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 +259,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 {
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestValidateViewGrantShareBinding(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { settings.Config.Server.SourceMap = nil })
|
||||
d := &requestContext{
|
||||
User: testUserWithView(1, "srv"),
|
||||
User: testUserWithView(1, "srv"),
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "abc123"},
|
||||
SourcePath: "/srv",
|
||||
@@ -215,7 +215,7 @@ func TestValidateViewGrantShareBinding(t *testing.T) {
|
||||
t.Fatalf("ValidateViewGrant: %v", err)
|
||||
}
|
||||
wrongShare := &requestContext{
|
||||
User: testUserWithView(1, "srv"),
|
||||
User: testUserWithView(1, "srv"),
|
||||
Share: share.Share{
|
||||
ShareColumns: share.ShareColumns{Hash: "other"},
|
||||
SourcePath: "/srv",
|
||||
@@ -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)
|
||||
|
||||
@@ -34,6 +34,29 @@ func createTokenAuthUser(t *testing.T, username string, perms users.Permissions)
|
||||
return &reloaded
|
||||
}
|
||||
|
||||
// registerApiToken mints a named API token for user, stores its metadata, and
|
||||
// registers its hash so auth can resolve it. configure, when non-nil, may adjust
|
||||
// the metadata before it is persisted.
|
||||
func registerApiToken(t *testing.T, user *users.User, name string, caps users.Permissions, minimal bool, configure func(*users.AuthToken)) string {
|
||||
t.Helper()
|
||||
tokenString, tokenMeta, err := auth.MakeSignedTokenAPI(user, name, time.Hour, caps, minimal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokenMeta.Name = name
|
||||
tokenMeta.Token = tokenString
|
||||
if configure != nil {
|
||||
configure(&tokenMeta)
|
||||
}
|
||||
if err = state.AddUserToken(user.Username, tokenMeta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = state.AddApiToken(tokenString, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tokenString
|
||||
}
|
||||
|
||||
func cookieRequest(t *testing.T, handler http.HandlerFunc, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -67,6 +90,53 @@ func TestApiTokenWithoutMetadataRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiTokenMinimalKeepsOwnerPermissions(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
originalAuthKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
|
||||
|
||||
user := createTokenAuthUser(t, "minimal-admin", users.Permissions{Admin: true, Api: true})
|
||||
tokenString := registerApiToken(t, user, "minimal-key", users.Permissions{}, true, nil)
|
||||
|
||||
rec := cookieRequest(t, withAdmin(mockHandler), tokenString)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("minimal API token: got status %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiTokenCustomizedZeroPermissionsForbiddenOnAdmin(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
originalAuthKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
|
||||
|
||||
user := createTokenAuthUser(t, "custom-zero-admin", users.Permissions{Admin: true, Api: true})
|
||||
tokenString := registerApiToken(t, user, "zero-key", users.Permissions{}, false, nil)
|
||||
|
||||
rec := cookieRequest(t, withAdmin(mockHandler), tokenString)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("customized zero-cap API token: got status %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiTokenLegacyEmptyCapsTreatedAsMinimal(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
originalAuthKey := settings.Config.Auth.Key
|
||||
settings.Config.Auth.Key = "key"
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
|
||||
|
||||
user := createTokenAuthUser(t, "legacy-minimal-admin", users.Permissions{Admin: true, Api: true})
|
||||
tokenString := registerApiToken(t, user, "legacy-key", users.Permissions{}, true, func(meta *users.AuthToken) {
|
||||
meta.Minimal = nil
|
||||
})
|
||||
|
||||
rec := cookieRequest(t, withAdmin(mockHandler), tokenString)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("legacy empty-cap API token: got status %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// A named API token with stored caps must have those caps applied at auth time.
|
||||
func TestApiTokenCapsAppliedFromStoredMetadata(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
@@ -77,18 +147,7 @@ func TestApiTokenCapsAppliedFromStoredMetadata(t *testing.T) {
|
||||
user := createTokenAuthUser(t, "capped-admin", users.Permissions{Admin: true, Api: true})
|
||||
|
||||
// The token cap excludes admin, so the resolved user must lose admin.
|
||||
tokenString, tokenMeta, err := auth.MakeSignedTokenAPI(user, "capped-key", time.Hour, users.Permissions{Share: true}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokenMeta.Name = "capped-key"
|
||||
tokenMeta.Token = tokenString
|
||||
if err := state.AddUserToken(user.Username, tokenMeta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.AddApiToken(tokenString, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokenString := registerApiToken(t, user, "capped-key", users.Permissions{Share: true}, false, nil)
|
||||
|
||||
rec := cookieRequest(t, withAdmin(mockHandler), tokenString)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
@@ -228,18 +287,7 @@ func TestApiTokenBasicAuthRoundTrip(t *testing.T) {
|
||||
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
|
||||
|
||||
user := createTokenAuthUser(t, "webdav-user", users.Permissions{Api: true})
|
||||
tokenString, tokenMeta, err := auth.MakeSignedTokenAPI(user, "obsidian-sync", time.Hour, users.Permissions{}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokenMeta.Name = "obsidian-sync"
|
||||
tokenMeta.Token = tokenString
|
||||
if err = state.AddUserToken(user.Username, tokenMeta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = state.AddApiToken(tokenString, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokenString := registerApiToken(t, user, "obsidian-sync", users.Permissions{}, true, nil)
|
||||
|
||||
basicRequest := func(token string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -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 {
|
||||
@@ -403,6 +406,9 @@ func userPatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
if err2 != nil {
|
||||
return http.StatusInternalServerError, err2
|
||||
}
|
||||
if !d.User.Permissions.Admin && uValue.LockPassword && state.FieldListIncludes(req.Which, "password") {
|
||||
return http.StatusForbidden, fmt.Errorf("password changes are disabled for this user")
|
||||
}
|
||||
req.User.ID = uValue.ID
|
||||
req.User.Username = uValue.Username
|
||||
if state.FieldListIncludes(req.Which, "otpEnabled") && !req.User.OtpEnabled {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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"
|
||||
)
|
||||
|
||||
func TestUserPatchLockPassword(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
locked := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "locked",
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(locked, "oldpassword"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
locked.LockPassword = true
|
||||
if err := state.UpdateUser(locked, "", "lockPassword"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
locked, err := getUser(t, "locked")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !locked.LockPassword {
|
||||
t.Fatal("lockPassword was not stored")
|
||||
}
|
||||
oldHash := locked.Password
|
||||
|
||||
patch := func(actor *users.User) int {
|
||||
body := []byte(`{"which":["password"],"data":{"password":"newpassword"}}`)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/users?username=locked", bytes.NewReader(body))
|
||||
req.Header.Set("X-Password", "oldpassword")
|
||||
status, _ := userPatchHandler(httptest.NewRecorder(), req, &Context{User: actor})
|
||||
return status
|
||||
}
|
||||
|
||||
if status := patch(locked); status != http.StatusForbidden {
|
||||
t.Fatalf("self password change with lockPassword: status=%d, want %d", status, http.StatusForbidden)
|
||||
}
|
||||
after, err := getUser(t, "locked")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Password != oldHash {
|
||||
t.Fatal("password changed despite lockPassword")
|
||||
}
|
||||
|
||||
if status := patch(admin); status != http.StatusNoContent {
|
||||
t.Fatalf("admin password reset of locked user: status=%d, want %d", status, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func getUser(t *testing.T, username string) (*users.User, error) {
|
||||
t.Helper()
|
||||
u, err := state.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
@@ -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,46 @@ 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
|
||||
var isDir bool
|
||||
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() {
|
||||
isDir = true
|
||||
deleteBytes, _ = state.IndexedPathBytes(ffs.source, indexPath, true)
|
||||
} else {
|
||||
deleteBytes = info.Size()
|
||||
}
|
||||
|
||||
// Collect the adjustments before deleting: a directory delete also has to release
|
||||
// folder quotas nested under it, and their sizes come from the index.
|
||||
var adjustments state.DeleteAdjustments
|
||||
if sourceInfo, ok := settings.Config.Server.NameToSource[ffs.source]; ok {
|
||||
adjustments = state.PrepareAccountedDelete(ffs.user, ffs.source, sourceInfo.Path, indexPath, "", isDir, deleteBytes)
|
||||
}
|
||||
|
||||
if err := ffs.fs.RemoveAll(ctx, requestPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := state.ApplyAccountedDelete(adjustments); err != nil {
|
||||
logger.Warningf("quota adjust after webdav delete failed: %v", err)
|
||||
}
|
||||
activity.RecordWebDAVUser(ffs.httpReq, ffs.user, activitydb.Entry{
|
||||
EventType: activitydb.EventDelete,
|
||||
Source: ffs.source,
|
||||
@@ -527,9 +638,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 +670,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 +711,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 +781,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 +834,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 +852,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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,8 +13,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
|
||||
"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 +1150,138 @@ func TestWebDAV_OPTIONS(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type failedDeleteFileSystem struct{ webdav.FileSystem }
|
||||
|
||||
func (failedDeleteFileSystem) RemoveAll(context.Context, string) error { return os.ErrPermission }
|
||||
|
||||
func TestWebDAVDeleteNestedQuota(t *testing.T) {
|
||||
for _, scope := range []string{"/", "/scoped"} {
|
||||
t.Run(scope, func(t *testing.T) {
|
||||
sourcePath := setupDeleteQuotaTest(t)
|
||||
user, err := state.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user.BackendScopes[0].Scope = scope
|
||||
nested, sibling := seedNestedQuotaFixture(t, sourcePath, scope, &user)
|
||||
ffs := &filteredFileSystem{fs: failedDeleteFileSystem{webdav.Dir(filepath.Join(sourcePath, scope))}, source: "Downloads", user: &user}
|
||||
if err = ffs.RemoveAll(context.Background(), "/public"); err == nil {
|
||||
t.Fatal("expected forbidden deletion")
|
||||
}
|
||||
ffs.filePerms = users.SourceFilePermissions{Delete: true}
|
||||
if err = ffs.RemoveAll(context.Background(), "/public"); err == nil {
|
||||
t.Fatal("expected underlying deletion failure")
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(nested.ID); used != 5 {
|
||||
t.Fatalf("failed delete changed usage: %d", used)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/dav/Downloads/public", nil)
|
||||
req.SetPathValue("source", "Downloads")
|
||||
req.SetPathValue("path", "/public")
|
||||
rec := httptest.NewRecorder()
|
||||
_, err = webDAVHandler(rec, req, &requestContext{User: &user})
|
||||
if err != nil || rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: code=%d err=%v body=%s", rec.Code, err, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sourcePath, scope, "public")); !os.IsNotExist(err) {
|
||||
t.Fatalf("ancestor still exists: %v", err)
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(nested.ID); used != 0 {
|
||||
t.Fatalf("nested usage=%d, want 0", used)
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(sibling.ID); used != 7 {
|
||||
t.Fatalf("sibling usage=%d, want 7", used)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodDelete, "/dav/Downloads/sibling/file", nil)
|
||||
req.SetPathValue("source", "Downloads")
|
||||
req.SetPathValue("path", "/sibling/file")
|
||||
rec = httptest.NewRecorder()
|
||||
if _, err := webDAVHandler(rec, req, &requestContext{User: &user}); err != nil || rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("file delete: code=%d err=%v", rec.Code, err)
|
||||
}
|
||||
if used, _ := state.GetQuotaCounterSnapshot(sibling.ID); used != 0 {
|
||||
t.Fatalf("file delete usage=%d, want 0", used)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user