Compare commits

..
112 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
Graham Steffaniak 91bc608f0e Beta/v2.1.2 (#3142) 2026-10-13 09:58:57 -05:00
Graham Steffaniak e21d553559 updated changelog for v2.1.2 (#3140) 2026-10-13 09:53:20 -05:00
Graham Steffaniak fde6857344 Merge branch 'main' into dev/v2.1.2 2026-10-13 09:33:59 -05:00
Graham Steffaniak 37aec9ebd8 fix: reject usernames that do not map to their own home directory (#3138) 2026-10-13 09:28:42 -05:00
dl-pt 4bf1ba312a fix(auth): prevent collisions between newly issued tokens (#3135) 2026-10-12 18:14:54 -05:00
+5 c8459eb404 fix: WebDAV OPTIONS (#3123)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: spatiumstas <79056064+spatiumstas@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Andreas Fehn <fehnomenal@fehn.systems>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Takahiro Shimizu <anatofuz@gmail.com>
Co-authored-by: Graham Steffaniak <gtsteffaniak@users.noreply.github.com>
Co-authored-by: Joel Town Road <13539685+hippi345@users.noreply.github.com>
Co-authored-by: beemines <3083452174@qq.com>
Co-authored-by: Bee mines <Beemines82@gmail.com>
Co-authored-by: dweebzxx <134675545+dweebzxx@users.noreply.github.com>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-10-12 12:15:16 -05:00
Walid KarrayandClaude Opus 5.5 241bbd69b4 fix(download): revalidate single-file downloads so a changed file is not served stale (#3133)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:52:51 -05:00
Graham Steffaniak 9de21cd896 Announce v2.0.0-stable (#3126) 2026-10-11 11:52:49 -05:00
planetryanandClaude Opus 5.5 fcec78b57c add Basque (eu) translation and fix Spanish (es) strings (#3118)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:05:54 -05:00
Kevin B. 12f27133b5 Remove editor quick save (#3122) 2026-10-11 09:05:38 -05:00
Kevin B. 20a5d75d7e fix language switcher in builds (#3117) 2026-10-10 12:33:46 -05:00
288 changed files with 17615 additions and 3466 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
+77 -1
View File
@@ -2,6 +2,83 @@
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**:
- [High] New accounts named with ".." breaks out of scoped folder on creation (GHSA-c84q-q5g6-r47f) -- reporter @dylanzonix
- [Medium] Fixed session token collision issue when multiple users login simultaneously. (GHSA-c9cr-w724-gqj3) (#3135) Thanks @dl-pt reporters: @Ishtiaque-Ahmed-Risat @AlexWhiplash @tonghuaroot
**Notes**:
- Removed "Editor quick save" setting (`userDefaults.fileViewer.quickSave`), the save button is now always present in the editor toolbar. (#3122)
- add Basque (eu) translation and fix Spanish (es) strings (#3118)
**Bug Fixes**:
- fix(webdav): OPTIONS now advertises only methods actually supported and permitted for the user; the DAV header reports class 1 only since LOCK/UNLOCK are not enabled (#3110)
- fix(download): revalidate single-file downloads so a changed file is not served stale (#3133)
- Language switcher not working (#3114) (#3117)
- Viewer showing a stale image after the file changed on disk: single-file downloads are now revalidated by the browser (#3132)
## v2.1.1
Note: this release also contains all changes from v2.0.10-beta changelog
@@ -34,7 +111,6 @@ Note: this release also contains all changes from v2.0.10-beta changelog
- fix(backend): skip denied paths in multi-file downloads, 404 when none remain (#3100) @hippi345
- fix: persist the user scope path chosen in the edit dialog (#3065) @mvanhorn
## v2.1.0
**Security**:
+1 -1
View File
@@ -18,7 +18,7 @@
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
:pushpin: [v2.0.0 is now stable!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) --> See [migration support discussion](https://github.com/gtsteffaniak/filebrowser/discussions/3105)
## About
+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)
}
}
-1
View File
@@ -18,7 +18,6 @@ server:
userDefaults:
fileViewer:
editorQuickSave: false
disableOnlyOfficeExt: ".md .txt .pdf"
disableViewingExt: ""
sidebar:
+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)
}
})
}
}
+8 -1
View File
@@ -6,9 +6,10 @@ import (
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// Auther is the authentication interface.
@@ -39,18 +40,24 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
if _, ok := user.Tokens[name]; ok {
return "", users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
}
id, err := utils.RandomHex(16)
if err != nil {
return "", users.AuthToken{}, fmt.Errorf("generate token id: %w", err)
}
now := time.Now()
expires := now.Add(duration)
// Create minimal token with only JWT standard claims
claim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
ID: id,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: FB_ISSUER,
},
},
}
claim.Minimal = &minimal
if !minimal {
claim.Permissions = users.SanitizeTokenPermissions(perms)
}
@@ -0,0 +1,56 @@
package auth
import (
"testing"
"testing/synctest"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSignedTokensAreUniqueAtSameTime(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-signing-key-for-token-uniqueness"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
// Freeze the clock so every token has identical issue and expiry times.
synctest.Test(t, func(t *testing.T) {
now := time.Now()
tokens := make(map[string]users.AuthToken)
for _, userID := range []uint64{1, 2} {
user := &users.User{ID: userID}
for _, minimal := range []bool{true, false} {
for range 2 {
tokenString, claim, err := MakeSignedTokenAPI(user, "test-token", time.Hour, users.Permissions{Api: true}, minimal)
if err != nil {
t.Fatal(err)
}
if _, exists := tokens[tokenString]; exists {
t.Fatalf("token collision for user %d with minimal=%v", userID, minimal)
}
tokens[tokenString] = claim
}
}
}
ids := make(map[string]bool)
for tokenString, claim := range tokens {
var parsed users.MinimalAuthToken
if _, err := jwt.ParseWithClaims(tokenString, &parsed, JWTSigningKeyFunc()); err != nil {
t.Fatalf("parse signed token: %v", err)
}
if parsed.ID == "" || ids[parsed.ID] {
t.Fatal("each signed token must have a unique nonempty id")
}
ids[parsed.ID] = true
if parsed.ID != claim.ID {
t.Fatal("signed token id must match returned metadata")
}
if parsed.IssuedAt == nil || !parsed.IssuedAt.Equal(now) || parsed.ExpiresAt == nil || !parsed.ExpiresAt.Equal(now.Add(time.Hour)) {
t.Fatal("token uniqueness must not depend on changing timestamps")
}
assertMinimalJWTClaims(t, tokenString)
}
})
}
+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")
}
}
@@ -21,3 +21,16 @@ func TestValidateUsernameAcceptsNormalName(t *testing.T) {
t.Fatalf("expected valid username, got %v", err)
}
}
func TestValidateUsernameRejectsUnsafeNames(t *testing.T) {
for _, username := range []string{"..", "....", ".", "-", "al..ice", "bob/", "./bob", `bob\`, "alice/docs", " alice", "alice "} {
if err := ValidateUsername(username); err == nil {
t.Fatalf("expected %q to be rejected", username)
}
}
for _, username := range []string{"alice", "first.last", "j.r.r", "a-b_c", "user@example.com", "José", "李雷", ".alice", "users"} {
if err := ValidateUsername(username); err != nil {
t.Fatalf("expected %q to be accepted, got %v", username, err)
}
}
}
+74 -11
View File
@@ -1,6 +1,7 @@
package users
import (
"encoding/json"
"fmt"
"strings"
@@ -47,8 +48,10 @@ var reservedUsernames = map[string]struct{}{
strings.ToLower(AnonymousUserName): {},
}
// ValidateUsername rejects empty and reserved login names.
// ValidateUsername rejects empty and reserved login names, and names that do not map to their
// own createUserDir home directory.
func ValidateUsername(username string) error {
raw := username
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("username is empty")
@@ -56,6 +59,11 @@ func ValidateUsername(username string) error {
if _, reserved := reservedUsernames[strings.ToLower(username)]; reserved {
return fmt.Errorf("username %q is reserved", username)
}
// The per-user home directory is derived from CleanUsername(username), so reject names
// that it would rewrite, the names "." and "-", and names containing path separators.
if CleanUsername(raw) != raw || raw == "." || raw == "-" || strings.ContainsAny(raw, `/\`) {
return fmt.Errorf("username %q contains characters that are not allowed", raw)
}
return nil
}
@@ -97,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"`
}
@@ -106,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+).
@@ -199,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"`
@@ -235,7 +299,6 @@ type BackendScope struct {
// json tags must match variable name with smaller case first letter
type NonAdminEditable struct {
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
+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)
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
@@ -497,6 +498,102 @@ func TestServeSingleFile_MissingScopedPathReturns404(t *testing.T) {
}
}
// TestServeSingleFile_RequiresRevalidation verifies that a single-file download is not
// reused from the browser cache without revalidation, so a file changed on disk is not
// served stale, and that an unchanged file still answers 304.
func TestServeSingleFile_RequiresRevalidation(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
ownerUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
d := &Context{User: &ownerUser}
rec := httptest.NewRecorder()
status, err := ServeSingleFile(rec, httptest.NewRequest(http.MethodGet, "/", nil), d, sourceName, "/shared/hello.txt", "hello.txt", ServeSingleFileOptions{ForceInline: true})
if err != nil {
t.Fatalf("unexpected error: status=%d err=%v", status, err)
}
if got := rec.Header().Get("Cache-Control"); got != "private, no-cache" {
t.Fatalf("expected Cache-Control %q, got %q", "private, no-cache", got)
}
lastModified := rec.Header().Get("Last-Modified")
if lastModified == "" {
t.Fatal("expected a Last-Modified header to revalidate against")
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("If-Modified-Since", lastModified)
rec = httptest.NewRecorder()
if _, err := ServeSingleFile(rec, req, d, sourceName, "/shared/hello.txt", "hello.txt", ServeSingleFileOptions{ForceInline: true}); err != nil {
t.Fatalf("conditional request: unexpected error: %v", err)
}
if rec.Code != http.StatusNotModified {
t.Fatalf("expected 304 for an unchanged file, got %d", rec.Code)
}
}
// TestServeSingleFile_SameSecondEditIsNotServedStale verifies that an edit within the same
// second as the cached copy, which If-Modified-Since cannot see, changes the ETag so a
// conditional request gets the new content rather than 304.
func TestServeSingleFile_SameSecondEditIsNotServedStale(t *testing.T) {
sourceRoot, sourceName, _ := setupShareArchiveDownloadTest(t)
ownerUser := users.User{
FrontendUser: users.FrontendUser{Username: "owner"},
BackendScopes: []users.BackendScope{{
Path: sourceRoot,
Scope: "/",
Permissions: users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
},
}},
}
users.SyncBackendSourcePermissionsMap(&ownerUser)
d := &Context{User: &ownerUser}
filePath := filepath.Join(sourceRoot, "shared", "hello.txt")
modTime := time.Date(2026, 1, 2, 3, 4, 5, 100_000_000, time.UTC)
if err := os.Chtimes(filePath, modTime, modTime); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
if _, err := ServeSingleFile(rec, httptest.NewRequest(http.MethodGet, "/", nil), d, sourceName, "/shared/hello.txt", "hello.txt", ServeSingleFileOptions{ForceInline: true}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
etag, lastModified := rec.Header().Get("ETag"), rec.Header().Get("Last-Modified")
// Same second, same size: only the ETag can tell the two versions apart.
if err := os.WriteFile(filePath, []byte("share-zip-CHANGED!"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(filePath, modTime.Add(time.Millisecond), modTime.Add(time.Millisecond)); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
req.Header.Set("If-Modified-Since", lastModified)
rec = httptest.NewRecorder()
if _, err := ServeSingleFile(rec, req, d, sourceName, "/shared/hello.txt", "hello.txt", ServeSingleFileOptions{ForceInline: true}); err != nil {
t.Fatalf("conditional request: unexpected error: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for a file edited within the same second, got %d", rec.Code)
}
if got := rec.Body.String(); got != "share-zip-CHANGED!" {
t.Fatalf("expected the new content, got %q", got)
}
}
// TestRawFilesHandler_ActivityLogExcludesDeniedPaths verifies that when a download
// mixes allowed and denied paths, only the allowed paths appear in the recorded
// activity entry (the denied path name must not be revealed via the audit log).
+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)
}
@@ -6,12 +6,87 @@ import (
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSessionTokensRemainIndependent(t *testing.T) {
setupTestEnv(t)
origKey := settings.Config.Auth.Key
origExp := settings.Config.Auth.TokenExpirationHours
settings.Config.Auth.Key = "test-signing-key-for-independent-sessions"
settings.Config.Auth.TokenExpirationHours = 2
t.Cleanup(func() {
settings.Config.Auth.Key = origKey
settings.Config.Auth.TokenExpirationHours = origExp
})
user := createTokenAuthUser(t, "session-user", users.Permissions{Api: true})
admin := createTokenAuthUser(t, "session-admin", users.Permissions{Admin: true, Api: true})
owners := []*users.User{user, user, admin}
tokens := make([]string, len(owners))
for i, owner := range owners {
var err error
tokens[i], err = mintAndRegisterSessionToken(owner)
if err != nil {
t.Fatal(err)
}
}
for i, token := range tokens {
if userID, isSession, ok := state.HashedTokenOwner(token); !ok || !isSession || userID != owners[i].ID {
t.Fatalf("session %d lost its owner: userID=%d isSession=%v ok=%v", i, userID, isSession, ok)
}
want := http.StatusForbidden
if owners[i].Permissions.Admin {
want = http.StatusOK
}
if rec := cookieRequest(t, withAdmin(mockHandler), token); rec.Code != want {
t.Fatalf("session %d: got status %d, want %d", i, rec.Code, want)
}
}
if err := state.RevokeToken(tokens[0]); err != nil {
t.Fatal(err)
}
for i, token := range tokens {
want := http.StatusOK
if i == 0 {
want = http.StatusUnauthorized
}
if rec := cookieRequest(t, withUser(mockHandler), token); rec.Code != want {
t.Fatalf("session %d after revocation: got status %d, want %d", i, rec.Code, want)
}
}
}
func TestSessionTokenWithoutIDRemainsValid(t *testing.T) {
setupTestEnv(t)
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-signing-key-for-legacy-session"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
user := createTokenAuthUser(t, "legacy-session-user", users.Permissions{Api: true})
now := time.Now()
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
Issuer: auth.FB_ISSUER,
}).SignedString([]byte(settings.Config.Auth.Key))
if err != nil {
t.Fatal(err)
}
if err := state.RegisterSessionToken(token, user.ID); err != nil {
t.Fatal(err)
}
if rec := cookieRequest(t, withUser(mockHandler), token); rec.Code != http.StatusOK {
t.Fatalf("legacy session: got status %d, want %d", rec.Code, http.StatusOK)
}
}
func TestReplaceSessionTokenKeepsOldTokenWhenMintFails(t *testing.T) {
setupTestEnv(t)
user := createTokenAuthUser(t, "rotation-user", users.Permissions{Api: true})
+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,
+15 -31
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 {
@@ -552,7 +531,12 @@ func ServeSingleFile(w http.ResponseWriter, r *http.Request, d *Context, source
}
SetContentDisposition(w, r, displayFileName, opts.ForceInline)
w.Header().Set("Cache-Control", "private")
// no-cache: the URL does not change when the file does, so browsers must revalidate
// (ServeContent answers 304 when unchanged) instead of reusing a stale copy. The ETag,
// from mtime in nanoseconds and size, catches an edit within the same second, which
// If-Modified-Since cannot; it costs no read of the file.
w.Header().Set("Cache-Control", "private, no-cache")
w.Header().Set("ETag", fmt.Sprintf(`"%x-%x"`, fileInfo.ModTime().UnixNano(), fileInfo.Size()))
w.Header().Set("X-Content-Type-Options", "nosniff")
var reader io.ReadSeeker = fd
+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)
}
}
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff. Show more