Compare commits

...
101 Commits
Author SHA1 Message Date
Graham Steffaniak 8415d5f1ff Fix install button (#3312) 2026-10-31 08:27:12 -05:00
Graham Steffaniak 85f934208c replace button with link 2026-10-31 08:20:44 -05:00
1022ecafef fix(frontend): address CodeRabbit follow-ups from #3276 and #3303 (#3311)
Co-authored-by: FandacqkaCorp <275647464+FandacqkaCorp@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 08:17:44 -05:00
Graham Steffaniak 062ac9fb2d Beta/v2.1.4 (#3307) 2026-10-30 19:14:33 -05:00
Graham SteffaniakandGraham Steffaniak cecdd79fcc Restrict Unix socket forwarded-header trust with peer credentials (#3309)
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-30 18:40:10 -05:00
Graham Steffaniak ac50ecfd18 fix: CodeRabbit follow-ups from #3307 (quick-win bugfixes) (#3308) 2026-10-30 17:56:50 -05:00
Graham Steffaniak 71cc2f3f1b fixed linting 2026-10-30 16:30:00 -05:00
Graham Steffaniak 1b3f47f176 fix sensitive data GHSA-892f-9h5p-mfc8 (#3306) 2026-10-30 16:25:25 -05:00
Graham Steffaniak 219b64b5ec addressed deep copy issue 2026-10-30 15:37:45 -05:00
Graham Steffaniak 3ef4928ed7 fix: prevent visible border in generated maskable PWA icons (#3300) 2026-10-30 15:33:33 -05:00
Graham Steffaniak df0a5ad10c fix sensitive data GHSA-892f-9h5p-mfc8 2026-10-30 15:22:46 -05:00
Graham Steffaniak a43d0ab2d3 updated changelog 2026-10-30 15:05:34 -05:00
Graham Steffaniak 9de09e5ec8 feat(search): sidebar File actions and close button in advanced search (#3303) 2026-10-30 15:02:51 -05:00
Graham Steffaniak 9d38c686a1 updated changelog 2026-10-30 15:01:15 -05:00
Graham Steffaniak 1cfca112e4 fix empty view after login if source named 'Files' (#3304) 2026-10-30 14:51:04 -05:00
Graham Steffaniak f540e22dc9 fix(quotas): complete delete accounting for bulk and nested resets (#3305) 2026-10-30 13:26:38 -05:00
Graham Steffaniak dade856ebe fix(move-copy): require choosing a destination when none can be inferred (advanced search) (#3276) 2026-10-30 13:19:15 -05:00
Graham SteffaniakandCursor 2f1da21253 fix(quotas): complete delete accounting for bulk and nested resets
Follow-up to #3235 / #3163: reset accounted folder quotas inside deleted
directories without index lookups, wire the same prepare/apply path into
bulk and public delete handlers, and dedupe scope quota selection.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 13:04:16 -05:00
Kevin B. de8d2acd43 fix empty view after login 2026-10-30 13:04:15 -05:00
Graham Steffaniak 6375be202e fix: release nested folder quotas when deleting their ancestor (#3235) 2026-10-30 12:59:12 -05:00
FandacqkaCorpandClaude Sonnet 5.5 816b99cee0 feat(search): add a close button to advanced search
On tool pages the top bar button only toggles the sidebar, so the only ways
out of advanced search were the sidebar or the browser back button.

Add an icon-only grey X at the top-left of the advanced search page (first
element of the tools wrapper); the top bar button is left as it is. It clears
the selection and goes back to the page the search was opened from, or to
/files when there is none (e.g. the URL was opened directly).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 16:14:12 +01:00
FandacqkaCorpandClaude Sonnet 5.5 bf349a7dcd feat(search): show the sidebar File actions in advanced search
The sidebar File actions button only exists in the listing, so in advanced
search the actions on the selected results (select all, download, copy, move)
were only reachable through the context menu, which is hard to find, especially
on touch devices.

Show the same sidebar button in advanced search. There it opens the context
menu on the current selection instead of the create-only menu, and it doesn't
depend on create permissions (the menu already checks permissions per
source). The menu now always offers Select multiple in advanced search, so
single results can be selected when "open with a single click" is on, and hides
the create (+) toggle, since results have no single folder to create in.

The button still honours the "hide sidebar file actions" preference and isn't
shown in shares.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 15:55:48 +01:00
Matt Van Horn db6504bf80 fix: prevent visible border in generated maskable PWA icons
Maskable icon generation now alpha-composites the resized artwork over
the opaque background using `OverlayCenter`. Added tests covering
transparent padding and opaque edges, verifying the output dimensions,
opacity, and background pixels outside the artwork.

Fixes #3187
2026-10-30 03:59:21 -07:00
FandacqkaCorpandClaude Sonnet 5.5 2019d573f8 fix(move-copy): require choosing a destination when there is no source to fall back to
In advanced search the open request is the list of results and has no source.
The Copy/Move prompt then showed "Choose a source" but, if the button was
pressed without choosing anything, silently sent the items to the root of the
first selected item's source.

Disable the Copy/Move button until a destination source is chosen in that
case (no destination yet and no source in the open request, outside shares),
and use the existing "Choose a source" text as its tooltip. Normal listings
and shares are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 11:05:37 +01:00
Matt Van Horn d716fc15ec test: share the nested quota delete fixture between API and WebDAV tests
TestResourceDeleteNestedQuota and TestWebDAVDeleteNestedQuota seeded the
same files, quotas and usage deltas. Move that setup into
seedNestedQuotaFixture and call it from both.
2026-10-30 00:28:00 -07:00
Graham Steffaniak 56e16608e4 fix(frontend): keep video loading spinner off when the fetch stalls mid-playback (#3289) 2026-10-29 19:19:47 -05:00
Graham Steffaniak c471fc135b fix(frontend): use native video fullscreen on iPhone (#3288) 2026-10-29 19:18:33 -05:00
Graham Steffaniak 5112cf87b1 fix(analytics): report freebsd instead of "other" in system.os (#3284) 2026-10-29 19:17:23 -05:00
Graham Steffaniak 3d7bac47ee fix(stream): play single-file share media on Windows (#3271) 2026-10-29 19:10:22 -05:00
vulragrag-star 2a38d2fa1e fix(users): enforce lockPassword on self-service password changes (#3283) 2026-10-29 19:01:34 -05:00
Matt Van Horn 82e110c08d test: cover scoped users in the API nested quota delete test
The API path is scope-relative while quota paths and the index are
source-relative. Run the test for a user scoped to /scoped as well as /,
which fails if the delete looks up the unscoped path.
2026-10-29 08:14:00 -07:00
Matt Van Horn f4c96078a1 Merge remote-tracking branch 'upstream/main' into fix/3163-release-nested-folder-quotas-on-delete
# Conflicts:
#	backend/internal/web/resource.go
#	backend/internal/web/webdav.go
#	backend/internal/web/webdav_test.go
2026-10-29 08:14:00 -07:00
975adedabb Address directory indexing bugs (#3292)
Co-authored-by: Lucas Siqueira <lucas.morais.siqueira@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:29:42 -05:00
Graham Steffaniak 14ec0eba0d Fix indexing regressions (#3260) 2026-10-27 19:41:12 -05:00
Lucas de Morais SiqueiraandGraham Steffaniak 307d5f6591 fix(indexing): skip unreadable directory entries instead of failing the listing (#3184)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-27 19:25:01 -05:00
Get The Fork Off ff9df43356 fix(frontend): keep video loading spinner off when the fetch stalls mid-playback 2026-10-27 20:50:30 -03:00
Get The Fork Off 802e91aa05 fix(frontend): use native video fullscreen on iPhone 2026-10-27 20:48:46 -03:00
NefelibateLGandZCode Scout 28ef44cdec fix(analytics): report freebsd instead of "other" in system.os
The analytics snapshot classified every GOOS outside {linux, windows,
darwin} as "other", so the shipped freebsd-amd64 release binary
reported "os": "other" in the analytics payload even though
backend/.goreleaser.yaml builds freebsd as a first-class target
(#3244).

Add freebsd to the mapSystemOS whitelist, matching the release build
matrix, and add a table-driven test covering all four shipped GOOS
values plus the unsupported and empty fallbacks.

Fixes #3244

Co-authored-by: ZCode Scout <noreply@zcode.local>
Signed-off-by: NefelibateLG <3453510037@qq.com>
2026-10-27 09:16:06 +08:00
sarankumar1325 0ee0f35301 fix(stream): play single-file share media on Windows
shareRelativeDisplayName ran filepath.Base on slash-separated paths. On
Windows filepath.Base("/") returns "\", which passed the "/" check, so a
single-file share streamed with file=/ got "\" as its name instead of the
shared file's name. IsMediaStreamFile then rejected it and the stream
endpoint answered 403 "stream endpoint supports audio and video only".

Use path.Base, since both the share-relative path and the share path are
slash-separated.

Fixes #3270
2026-10-25 11:30:00 +05:30
Graham Steffaniak cc99aa542e updated with symlink extraction change (#3265) 2026-10-23 15:36:09 -05:00
8514da4a9a fix(auth): API token permission handling for customized tokens (#3255)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-23 14:48:18 -05:00
Graham SteffaniakandDevin 4625b89528 fix webp preview support (#3259)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 15:34:57 -05:00
Jay Hemnani b2a07196b7 fix(archive): extract tar hard links instead of dropping them (#3234) 2026-10-22 15:26:16 -05:00
Graham Steffaniak 0a8698ee4d updated changelog 2026-10-22 15:24:08 -05:00
ec8ad36c95 Feature/html preview navigation and media (#3131)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 15:13:31 -05:00
vulragrag-star 2c0c1b4bb2 fix(frontend): show the share banner to public visitors (#3250) 2026-10-22 13:38:49 -05:00
Thorsten Liborius 6fa71bd13d fix(frontend): show correct relative expiration time for sharesFix/share expiry relative time (#3247) 2026-10-21 18:47:44 -05:00
Graham Steffaniakandcoderabbitai[bot] 06aa16e7d7 fix raw embedded image support - rtf rw2 raw rwl (#3243)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-20 17:43:38 -05:00
Graham Steffaniak b0f6d53f49 fix arm32 build in v2.1.3 (#3242) 2026-10-20 14:57:09 -05:00
Graham Steffaniak d27ab6a00b fix arm32 build in v2.1.3 2026-10-20 14:56:36 -05:00
Graham Steffaniak f0fb859479 updated package dependencies and replaced katex with temml (#3241) 2026-10-20 13:51:58 -05:00
Graham Steffaniak 4c8a913377 updated backage dependencies and replaced katex with temml 2026-10-20 13:31:43 -05:00
Graham Steffaniak c1684baffd Beta/v2.1.3 (#3238) 2026-10-20 12:47:40 -05:00
Matt Van Horn bb45ebcce1 fix: avoid shadowing err in nested quota delete tests 2026-10-20 10:41:27 -07:00
18138b1ab5 Fix CodeRabbit findings from PR #3238 (#3240)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
2026-10-20 12:31:48 -05:00
Matt Van Horn 26665efc13 fix: release nested folder quotas when deleting their ancestor
Adds shared preparation and application helpers for API and WebDAV
deletion accounting. Before deletion removes index metadata, each nested
accounted quota is measured independently, while covering, scope, and
share quotas use the deleted item's size. Adjustments are applied only
after deletion succeeds, and unknown sizes are omitted. API deletion
also derives the source index path from the item's real path.

Fixes #3163
2026-10-20 04:39:51 -07:00
Graham SteffaniakandDevin 98851e029f fix(access): match lowercase rule keys in path existence and rule updates (#3237)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 13:50:08 -05:00
4a1a3bbe09 fix(access): lowercase access-rule keys with migration (GHSA-4vrc-935w-xxq2) (#3232)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-10-19 13:50:08 -05:00
Graham Steffaniak a6d7f7c64c fix(auth): password-gate passkey enrollment changes (GHSA-r744-4vm3-2px3) (#3198) 2026-10-19 13:50:08 -05:00
Graham Steffaniak ada7a18620 Use statx reduce redundant calls (#3230) 2026-10-19 13:50:08 -05:00
Graham Steffaniak 4f4ea2370e updated linting and tests on macos (#3231) 2026-10-19 13:50:08 -05:00
Graham Steffaniak 52aa314e1d fix(quota): count upload usage against accounted limits (GHSA-m57f-66qj-8jxg) (#3201) 2026-10-19 13:50:08 -05:00
Kevin B.andGraham Steffaniak 1d6f67cc20 update frontend deps (v2.1.3) (#3228)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-19 13:50:08 -05:00
Graham Steffaniak d89b7e7deb fix(events): hold client locks during SSE sends (GHSA-p9wm-f5jj-75pj) (#3227) 2026-10-19 12:37:04 -05:00
Graham Steffaniak e26bd29848 fix(archive): enforce access on unarchive extraction (GHSA-p93p-v944-4p44) (#3203) 2026-10-19 12:37:04 -05:00
Graham Steffaniak 9c2b98655a fix: enforce readOnly on WebDAV, archive and OnlyOffice (GHSA-4v62-884x-2466) (#3202) 2026-10-19 12:37:04 -05:00
Paolo VallettaandGraham Steffaniak a379f047d1 fix(indexing): resume the persisted scan schedule on startup (#3225)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-19 12:37:04 -05:00
Kevin B.andGraham Steffaniak f4879be6bf fix path picker list on shares (#3223)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-19 12:23:45 -05:00
Kevin B.andGraham Steffaniak 7751da745d Hide activity, quota, access buttons in Info prompt in shares (#3224)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-19 12:23:17 -05:00
Graham Steffaniak 80cedcb4d5 fix(frontend): clear stale upload navigation approval when idle (#3222) 2026-10-18 13:48:38 -05:00
Graham Steffaniak ac19abf7d2 fix(frontend): restore upload guards while uploads are paused (#3220) 2026-10-18 13:48:38 -05:00
Graham Steffaniak 38e868e5af fix(frontend): resolve /files/ to default source for single-source installs (#3215) 2026-10-18 13:48:38 -05:00
anonymous-writerandGraham Steffaniak b3af5cc2d7 Fix upload session not released after successful single-file upload (#3186)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-18 13:48:38 -05:00
Paolo VallettaandGraham Steffaniak b399de3d92 fix(webdav): free the deleted bytes from the quota counters (#3160)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-18 13:45:50 -05:00
Graham Steffaniak 99f1a14e7b fix(shares): correct share path updates on folder move (GHSA-26fh-r34c-qxh6) (#3197) 2026-10-18 13:07:33 -05:00
Graham Steffaniak 84afd8b0b7 fix(webdav): require Create permission for new PUT files (GHSA-8655-2ph3-q7q7) (#3196) 2026-10-18 13:07:33 -05:00
Graham Steffaniak 78be5d8c0f fix(preview): enforce scoped symlink resolution (GHSA-g2q5-r6ch-v25f) (#3195) 2026-10-18 13:07:33 -05:00
Graham Steffaniak 5e29e1ee60 fix(auth): use rightmost X-Forwarded-For for client IP (GHSA-wjp8-g49g-xrhx) (#3191) 2026-10-18 13:07:33 -05:00
Graham Steffaniak 367a7f019c fix(tools): per-user duplicate finder cache (GHSA-4fhx-8pf6-5gmr) (#3190) 2026-10-18 13:07:33 -05:00
Graham Steffaniak fa9c094160 fix(view): block share view tokens on authenticated view route (GHSA-wxj5-g3qp-g732) (#3194) 2026-10-18 13:07:33 -05:00
Graham Steffaniak 36a9a6dc49 fix(events): prevent SSE EventRouter concurrent map panic (GHSA-p9wm-f5jj-75pj) (#3189) 2026-10-18 13:07:33 -05:00
Kevin B.andGraham Steffaniak 8fe6f1d5c3 Refactor Tooltip and generic to avoid v-html usage (GHSA-rmjp-pfrq-cc3v) (#3199)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-18 13:07:33 -05:00
JUSHUANGHUI LIandGraham Steffaniak 6dc3935c57 fix: preserve file permissions when saving edits (#3159)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-17 11:28:25 -05:00
Graham Steffaniak 5e73036657 fix(users): partial global permissions on API create (on top of #3171) (#3165) 2026-10-17 11:24:46 -05:00
Jay HemnaniandGraham Steffaniak e505900975 fix(activity): record uploads and deletes made through a share (#3166)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-17 11:24:46 -05:00
HawkForgeandGraham Steffaniak 26f320ee43 fix(settings): don't panic when config has no server map (#3176)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-16 16:23:41 -05:00
Kevin B. 184ae798c4 make listing highlight follow nextPrevious (#3156) 2026-10-15 15:26:59 -05:00
Graham Steffaniak 7ae8bab062 fix scope issues for archive extraction (#3177) 2026-10-15 15:26:59 -05:00
Graham Steffaniak 1af606e751 fix dev script 2026-10-15 15:26:59 -05:00
Graham Steffaniak 3683bc607c fixed markdown screenshots (#3171) 2026-10-15 15:26:59 -05:00
Som SamantrayandGraham Steffaniak 08d3e43c22 fix(auth): preserve admin source scope with createUserDir (#3125)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-15 15:26:59 -05:00
Kevin B.andGraham Steffaniak d76f9373c4 Fix missing creation time and tiny font on md viewer code blocks (#3157)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-15 15:00:55 -05:00
Kevin B.andGraham Steffaniak a72651e7aa Add alphabetical order + pinned indicators to file tree (#3154)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-15 15:00:28 -05:00
Kevin B.andGraham Steffaniak 73d0b20821 Fix: vite warnings and restricted origin (#3155)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-15 15:00:00 -05:00
Anatolii PrylutskyiandGraham Steffaniak 3e447307f7 fix(frontend): hide write actions in read-only sources (#3149)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-14 18:50:09 -05:00
vulragrag-starandGraham Steffaniak 264cc81c41 fix(share): honor userDefaults singleClick for anonymous shares (#3147)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-14 18:46:13 -05:00
Graham Steffaniak aa5b24119a updated changelog 2026-10-14 16:07:31 -05:00
Graham Steffaniak 52cf624711 add file modify logging (#3152) 2026-10-14 16:07:31 -05:00
Graham Steffaniak 5753f63970 updated webdav hardening (#3151) 2026-10-14 16:07:31 -05:00
Jay HemnaniandGraham Steffaniak 8f1cf54cb0 fix(users): keep requested permissions when creating a user (#3130)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-14 16:07:31 -05:00
Paolo VallettaandGraham Steffaniak a8cfcdd6eb feat(webdav): chunked uploads for large files (rclone --owncloud-chunk-size) (#3112)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-10-14 16:05:49 -05:00
272 changed files with 15030 additions and 2577 deletions

No files matched your search

+8
View File
@@ -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
+61
View File
@@ -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
View File
@@ -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
+42
View File
@@ -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=
+3
View File
@@ -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.
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+27 -3
View File
@@ -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,
+84
View File
@@ -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 {
+9 -5
View File
@@ -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
}
+126 -9
View File
@@ -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 {
+77 -50
View File
@@ -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
}
+4 -2
View File
@@ -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)}
+1 -1
View File
@@ -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)
}
})
}
}
+1
View File
@@ -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)
}
+81 -76
View File
@@ -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,
+8 -3
View File
@@ -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)
}
+57
View File
@@ -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")
}
}
+66 -9
View File
@@ -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"`
+4 -20
View File
@@ -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)
}
+2 -2
View File
@@ -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)
+81
View File
@@ -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})
+6 -5
View File
@@ -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)
+8 -7
View File
@@ -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 ""
}
+98 -11
View File
@@ -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
+154 -3
View File
@@ -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)
}
}
+15 -1
View File
@@ -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
+10
View File
@@ -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 {
-9
View 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)
+5 -1
View File
@@ -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()
}
+15 -37
View File
@@ -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)
}
}
+141 -18
View File
@@ -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] = &quotaCounterMem{UsedBytes: 100, ReservedBytes: 7}
}
shareID := quota.ShareQuotaID("share")
quotaCounters["scope"] = &quotaCounterMem{UsedBytes: 100, ReservedBytes: 7}
quotaCounters[shareID] = &quotaCounterMem{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"] = &quotaCounterMem{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)
}
}
+7 -2
View File
@@ -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")
}
}
+10
View File
@@ -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()
+17
View File
@@ -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)
+66 -2
View File
@@ -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)
+315 -4
View File
@@ -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)
}
}
+23
View File
@@ -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
View File
@@ -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")
}
+215 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
+5
View File
@@ -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 {
+18
View File
@@ -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)
}
})
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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 {
+17 -7
View File
@@ -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
}
+5 -1
View File
@@ -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++
+5 -4
View File
@@ -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)
+126
View File
@@ -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
+67 -4
View File
@@ -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")
+50 -34
View File
@@ -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)
}
}
+108 -44
View File
@@ -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
+7 -4
View File
@@ -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)
}
+40 -17
View File
@@ -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")
}
}
+16
View File
@@ -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")
}
}
+1
View File
@@ -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,
+9 -30
View File
@@ -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 {
+35 -2
View File
@@ -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)
+72 -24
View File
@@ -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()
+7 -1
View File
@@ -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
}
+6
View File
@@ -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"},
+54
View File
@@ -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)
}
}
+16
View File
@@ -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 == "" {
+161 -1
View File
@@ -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:
+139
View File
@@ -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)
}
})
}
}
+332
View File
@@ -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
}
+539
View File
@@ -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