Compare commits

...
116 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
+5 8f2e8add9a Beta/v2.1.1 (#3109)
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-09 19:18:51 -05:00
Stephan Paternotte 6e98a9d4c8 Update nl.json (#3089) 2026-10-07 14:42:20 -05:00
Andreas Fehn c74817b1ab update german translations (#3062) 2026-10-03 19:14:32 -05:00
Matt Van HornandMatt Van Horn ed9b29f2e1 fix: apply theme toggles on anonymous public shares (#3049)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-10-02 15:25:50 -05:00
571 changed files with 35878 additions and 11271 deletions

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_REPO="${IMAGE_REPO:-gtstef/filebrowser}"
SLIM_SUFFIX="${SLIM_SUFFIX:-}"
if [[ -z "${GITHUB_OUTPUT:-}" ]]; then
echo "GITHUB_OUTPUT is not set" >&2
exit 1
fi
write_outputs() {
local update_track="$1"
local update_major="$2"
echo "update_track=${update_track}" >> "$GITHUB_OUTPUT"
echo "update_major=${update_major}" >> "$GITHUB_OUTPUT"
}
registry_version() {
local tag="$1"
local ver err err_file
err_file="$(mktemp)"
if ! ver="$(docker buildx imagetools inspect "${IMAGE_REPO}:${tag}" \
--format '{{ with index .Image "linux/amd64" }}{{ index .Config.Labels "org.opencontainers.image.version" }}{{ end }}' 2>"$err_file")"; then
err="$(cat "$err_file")"
rm -f "$err_file"
if grep -qiE 'not found|manifest unknown' <<<"$err"; then
ver=""
else
echo "Failed to inspect ${IMAGE_REPO}:${tag}: ${err}" >&2
exit 1
fi
else
rm -f "$err_file"
if [[ -z "$ver" ]]; then
echo "Missing org.opencontainers.image.version on ${IMAGE_REPO}:${tag} (linux/amd64)" >&2
exit 1
fi
fi
ver="${ver#v}"
printf '%s' "$ver"
}
semver_allows_update() {
local new="$1"
local incumbent="$2"
if [[ -z "$incumbent" ]]; then
return 0
fi
local winner
winner="$(printf '%s\n' "$incumbent" "$new" | sort -V | tail -n1)"
[[ "$winner" == "$new" ]]
}
ref="${GITHUB_REF_NAME:-}"
ref="${ref#v}"
if [[ ! "$ref" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
echo "Tag '${GITHUB_REF_NAME:-}' has no X.Y.Z-suffix form; leaving major-only floats enabled, track floats disabled"
write_outputs false true
exit 0
fi
new_version="$ref"
semver_core="${BASH_REMATCH[1]}"
suffix="${BASH_REMATCH[2]}"
major="$(echo "$semver_core" | cut -d. -f1)"
track=""
case "$suffix" in
beta) track="beta" ;;
stable) track="stable" ;;
*)
echo "Tag suffix '${suffix}' is not beta/stable; leaving major-only floats enabled, track floats disabled"
write_outputs false true
exit 0
;;
esac
track_tag="${track}${SLIM_SUFFIX}"
major_tag="${major}-${track}${SLIM_SUFFIX}"
incumbent_track="$(registry_version "$track_tag")"
incumbent_major="$(registry_version "$major_tag")"
update_track=false
if semver_allows_update "$new_version" "$incumbent_track"; then
update_track=true
fi
update_major=false
if semver_allows_update "$new_version" "$incumbent_major"; then
update_major=true
fi
echo "Floating tag policy (${SLIM_SUFFIX:-full} image):"
echo " New version: ${new_version}"
echo " Track tag: ${track_tag} (incumbent: ${incumbent_track:-<none>}) -> update_track=${update_track}"
echo " Major tag: ${major_tag} (incumbent: ${incumbent_major:-<none>}) -> update_major=${update_major}"
write_outputs "$update_track" "$update_major"
-41
View File
@@ -35,44 +35,3 @@ jobs:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_beta_release:
needs: [test_playwright]
if: startsWith(github.event.head_commit.message, 'Beta/v')
name: Create Beta Release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
fetch-depth: 0
- name: Extract version from commit message
id: extract_version
shell: bash
run: |
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
exit 1
fi
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
- name: Tag release commit on main
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
TAG="${{ steps.extract_version.outputs.tag_name }}"
git tag -f "$TAG" "${{ github.sha }}"
git push origin "refs/tags/$TAG" --force
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_version.outputs.tag_name }}
prerelease: false
make_latest: false
draft: false
generate_release_notes: true
overwrite_existing: true
name: ${{ steps.extract_version.outputs.tag_name }}
+10 -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,21 +40,17 @@ 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
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i
- name: Lint frontend
working-directory: frontend
run: npm run lint
- name: Lint frontend CSS
working-directory: frontend
run: npm run lint:css
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
@@ -61,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
@@ -70,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
@@ -79,10 +80,11 @@ 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
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run icons:check
run: npm i && npm run icons:check
+34 -18
View File
@@ -61,18 +61,23 @@ jobs:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Decide floating Docker tags
id: floating
run: bash .github/scripts/docker-floating-tag-policy.sh
- name: Modify tags (strip 'v' and add version tags)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
MODIFIED_JSON=$(echo "$JSON" | jq \
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not)
) |
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor and major tags
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor (always) and major tags (when allowed)
.tags += [
.tags[]
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
@@ -83,18 +88,20 @@ jobs:
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
if $updateMajor then
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
else empty end
] |
# Add suffix-only tags for beta (only if tag ends with -beta)
if any(.tags[]; split(":")[-1] | endswith("-beta")) then
# Add suffix-only tags for beta when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta"
]
else . end |
# Add suffix-only tags for stable (and latest) (only if tag ends with -stable)
if any(.tags[]; split(":")[-1] | endswith("-stable")) then
# Add suffix-only tags for stable (and latest) when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
@@ -146,19 +153,26 @@ jobs:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Decide floating Docker tags
id: floating
env:
SLIM_SUFFIX: -slim
run: bash .github/scripts/docker-floating-tag-policy.sh
- name: Modify tags (strip 'v' and add 'slim' if needed)
id: modify-json-slim
run: |
JSON='${{ steps.meta.outputs.json }}'
MODIFIED_JSON=$(echo "$JSON" | jq '
MODIFIED_JSON=$(echo "$JSON" | jq \
--argjson updateTrack '${{ steps.floating.outputs.update_track == 'true' }}' \
--argjson updateMajor '${{ steps.floating.outputs.update_major == 'true' }}' '
.tags |= map(
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not) # Remove the "latest" tag
| . + "-slim" # Append "-slim" to the versioned tag
) |
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor and major tags
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor (always) and major tags (when allowed)
.tags += [
.tags[]
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
@@ -169,18 +183,20 @@ jobs:
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
if $updateMajor then
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
else empty end
] |
# Add suffix-only tags for beta-slim (only if tag ends with -beta-slim)
if any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
# Add suffix-only tags for beta-slim when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
.tags += [
"gtstef/filebrowser:beta-slim",
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
]
else . end |
# Add suffix-only tags for stable-slim (only if tag ends with -stable-slim)
if any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
# Add suffix-only tags for stable-slim when semver policy allows
if $updateTrack and any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
.tags += [
"gtstef/filebrowser:stable-slim",
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
+1
View File
@@ -46,6 +46,7 @@ frontend/.tmp-icons-*
/backend/internal/web/dist
/backend/internal/web/embed
/backend/debug
/backend/http
.DS_Store
node_modules
+134
View File
@@ -2,6 +2,115 @@
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
**New Features**:
- User and group picker with partial, case-insensitive search when creating access rules (#3085), assigning groups on user edit, and choosing members on group edit.
- New `PUT /api/access/user-groups` endpoint that atomically replaces a user's group memberships.
- feat(search): configure the advanced search result limit (#3058) @beemines
- feat(listing): add sortable Date added and Type columns (#3052) @dweebzxx
- feat(archive): suggest and preview ZIP filename encodings (#3024) @AnaTofuZ
**Notes**:
- Group API breaking changes: `POST /api/access` returns 400 when `ruleCategory=group` names a nonexistent group or `ruleCategory=user` names a nonexistent user, group endpoints return 400/409 instead of 500 for client errors, and `PUT /api/access/group` accepts a `create` flag and returns `unknownMembers`. Existing database records are unaffected; no migration is required.
- update halloween theme (#3055)
- Add soft wrap option to the editor config (#3063)
- add better screenshot view handling (#3059)
- Update nl.json (#3089) @Stephan-P
- update german translations (#3062) @fehnomenal
- Update dev dependencies, add biome, fix types and linting (#3075) @Kurami32
**Bug Fixes**:
- Groups assigned in the user edit prompt now refresh the Groups settings list.
- Creating a group with an existing name now returns a conflict instead of silently overwriting its members.
- Access rules can no longer create empty groups from typos; group rules must reference an existing group.
- Empty group names are rejected and new group names must be 2-128 characters without control characters (existing records are unaffected); deleting or renaming a user now updates group memberships.
- fix(auth): use login endpoint for existing TOTP authentication (#3074)
- fix: apply theme toggles on anonymous public shares (#3049) @mvanhorn
- fix(frontend): keep source named files in extractSourceFromPath (#3099) (#3095)
- fix(auth): identify login fields for password autofill (#3077)
- 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**:
@@ -70,6 +179,31 @@ All notable changes to this project will be documented in this file. For commit
- On Linux, source partition totals sum distinct filesystems mounted under the source root (nested mounts) so usage bars match indexed content (#2761)
- Context menu stuck when opened from a listing in a prompt like move/copy or search (#3028).
## v2.0.10
**Security**:
- [High] Restricted custom API tokens (`minimal=false`) can no longer be upgraded to a full-permission session via `POST /api/auth/renew`; renew accepts only web session tokens registered in `hashed_tokens` (GHSA-6gr6-5qpq-888p) -- thanks @tao0845.
- [High] TOTP re-enrollment via `POST /api/auth/otp/generate` and `POST /api/auth/otp/verify` no longer allows anonymous callers to replace an existing second factor using only the account password; reset or replace requires an authenticated self or admin session (first-time enrollment without MFA unchanged) (GHSA-qx86-4v5r-26g5) -- thanks @tao0845.
- [Low] Public share lyrics and subtitle media routes now honor the share's file-viewer setting, download disable flag, and download limits, matching the public download route (GHSA-p7x3-p5jj-9xfh) -- thanks Yves Soete of Blacksight LLC. @yssoe
**New Features**:
- Require password change at next login for password-based users: new user setting `requirePasswordChange` (user defaults + per-user admin toggle), login blocked until the user sets a new password; bootstrap admins with a generated initial password get this automatically ([#2977](https://github.com/gtsteffaniak/filebrowser/issues/2977)). Generated bootstrap passwords use a speakable `word-xxxxx-xx` form (random word from a fixed list plus a 5-character and 2-character code) instead of a long hex string, for easier handoff before the forced change.
- Config YAML expands `$VAR` and `${VAR}` so values such as `userPassword: "${FILEBROWSER_LDAP_USER_PASSWORD}"` work as documented (#3042).
**Notes**:
- Sidebar navigation tree rows are real hyperlinks: middle-click, Ctrl/Cmd+click, and Shift+click use the browser’s default new-tab or new-window behavior.
- Sidebar source links can now switch between aggregated usage (default) and a root-filesystem-only view via a new "Limit disk usage to source filesystem" toggle.
- [docker] upgraded ffmpeg from 9.0 to 9.0.2
- CLI setup command no longer produces full config, instead a minimal config.yaml with comments.
**Bugfixes**:
- Fixed slow or broken file listing when `http.baseURL` is a subpath (for example `/files/`): the default source redirect navigated to `/files/{source}` on top of the app base URL, producing `/files/files/{source}` and resolving the wrong storage source until the route recovered.
- Anonymous visitors on public shares could not play inline video or audio: `GET /public/api/media/stream` returned 403 because view grants were validated before share context was fully hydrated; playback now works when download and the file viewer are allowed ([#3041](https://github.com/gtsteffaniak/filebrowser/issues/3041)).
- Fixed inflated disk usage for sources spanning multiple ZFS datasets (or btrfs subvolumes): shared-pool filesystems are now grouped by pool and counted once, instead of multiplying capacity by the number of mounted datasets (#3025) (#2997) (#2761).
- LDAP `userGroups` matching now accepts CN-only config values against full `memberOf` DNs and compares case-insensitively, instead of failing the shared auth helper with strict string equality (#3044).
- OIDC session expires despite tokenExpirationHours (#3006).
- Media playback becomes stuck after opening a failing media file since v2.0.8-beta (#3031)
## v2.0.9
**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
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:9.0.1-decode AS ffmpeg
FROM gtstef/ffmpeg:9.0.2-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
@@ -14,5 +14,9 @@ server:
config:
defaultEnabled: true
userDefaults:
listing:
viewMode: "normal"
# Size 4: switch-view "gallery" family resolves to icons (not gallery); list family → list.
gallerySize: 3
account:
showAdvancedProfile: true
@@ -12,7 +12,7 @@ import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
*/
export default defineConfig<PlaywrightFixtureOptions>({
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 5000,
timeout: 60_000,
testDir: "./tests/playwright/screenshots",
/* Run tests in files in parallel */
fullyParallel: false,
+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=
+38 -20
View File
@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
@@ -225,19 +224,11 @@ func createConfig(configpath string, noInput bool) error {
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
}
for {
levels := askQuestion(reader, "What should the log levels be?", "info|warning|error")
checkLevels := SplitByMultiple(levels)
invalidOptions := []string{}
for _, level := range checkLevels {
if !(level == "info" || level == "warning" || level == "error" || level == "debug") {
invalidOptions = append(invalidOptions, level)
}
}
if len(invalidOptions) == 0 {
break
}
fmt.Printf("Error: invalid options given '%s'. valid options: 'info|warning|error|debug'.\n", invalidOptions)
config.Server.Logging = []settings.LogConfig{
{
Output: "stdout",
Levels: "info|warning|error",
},
}
for {
@@ -250,9 +241,20 @@ func createConfig(configpath string, noInput bool) error {
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
}
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
config.Auth.Methods.PasswordAuth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
config.Auth.Methods.PasswordAuth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
const defaultFrontendName = "FileBrowser Quantum"
brandName := askQuestion(reader, "What should the application brand name be?", defaultFrontendName)
if brandName != defaultFrontendName {
config.Frontend.Name = brandName
}
adminUsername := askQuestion(reader, "What should the default admin username be?", "admin")
if adminUsername != "admin" {
config.Auth.Methods.PasswordAuth.AdminUsername = adminUsername
}
const adminPasswordGenerateDefault = "generate on startup"
adminPassword := askQuestion(reader, "What should the default admin password be?", adminPasswordGenerateDefault)
if adminPassword != "" && !strings.EqualFold(adminPassword, adminPasswordGenerateDefault) {
config.Auth.Methods.PasswordAuth.AdminPassword = adminPassword
}
modifyDefault := askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.Server.Sources[0].Config.DefaultPermissions = settings.NormalizeSourceFilePermissions(users.SourceFilePermissions{
@@ -266,12 +268,12 @@ func createConfig(configpath string, noInput bool) error {
fmt.Println("--- Configuration Complete ---")
yamlData, err := yaml.Marshal(&config)
yamlData, err := generateSetupConfigYAML(&config)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
return fmt.Errorf("generating config YAML: %w", err)
}
err = os.WriteFile(configpath, yamlData, 0600)
err = os.WriteFile(configpath, []byte(yamlData), 0600)
if err != nil {
return fmt.Errorf("writing config file: %w", err)
}
@@ -291,6 +293,22 @@ func createConfig(configpath string, noInput bool) error {
return nil
}
func generateSetupConfigYAML(config *settings.Settings) (string, error) {
embeddedPaths := []string{
"frontend/public/config.generated.yaml",
"../frontend/public/config.generated.yaml",
"internal/web/dist/config.generated.yaml",
"../internal/web/dist/config.generated.yaml",
}
for _, path := range embeddedPaths {
embeddedYaml, err := os.ReadFile(path)
if err == nil && len(embeddedYaml) > 0 {
return settings.GenerateConfigYamlWithEmbedded(config, true, false, false, string(embeddedYaml))
}
}
return settings.GenerateConfigYaml(config, true, false, false)
}
func generateYaml() {
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if generateConfig || settings.Env.IsDevMode {
+24
View File
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/alecthomas/kong"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -191,3 +192,26 @@ func TestSetupNoInputRejected(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "interactive input")
}
func TestGenerateSetupConfigYAML_MinimalWithComments(t *testing.T) {
t.Chdir("..")
cfg := settings.SetDefaults(false)
cfg.Server.Sources = []*settings.Source{{Path: "/var/lib/files"}}
cfg.Server.DatabaseV2.Path = "./filebrowser.sqlite"
cfg.Http.Port = 8080
cfg.Server.Logging = []settings.LogConfig{
{Output: "stdout", Levels: "info|warning|error"},
}
out, err := generateSetupConfigYAML(&cfg)
require.NoError(t, err)
assert.Contains(t, out, "#")
assert.Contains(t, out, "/var/lib/files")
assert.Contains(t, out, "8080")
assert.NotContains(t, out, "FileBrowser Quantum")
assert.NotContains(t, out, "darkMode")
assert.NotContains(t, out, "minSearchLength:")
assert.NotContains(t, out, "frontend:")
assert.NotContains(t, out, "rules:")
}
+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,24 +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/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
)
@@ -48,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
@@ -79,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
@@ -89,13 +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/text v0.42.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=
-1
View File
@@ -1 +0,0 @@
../../frontend/dist
+1 -1
View File
@@ -53,7 +53,7 @@ func TestUserUpdateChangesFiltersUnchangedFields(t *testing.T) {
which := []string{
"preview", "stickySidebar", "darkMode", "locale", "singleClick", "sorting",
"showHidden", "dateFormat", "themeColor", "quickDownload", "fileLoading",
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin",
"sidebarLinks", "deleteAfterArchive", "preferEditorForMarkdown", "showFirstLogin", "requirePasswordChange",
}
changes := UserUpdateChanges(before, &after, which, false)
+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)}
@@ -0,0 +1,30 @@
package fileutils
// PartitionUsage is a capacity view of a source path: total and used bytes.
type PartitionUsage struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
}
// singlePathPartitionUsage statfs's a single path: the filesystem covering it.
func singlePathPartitionUsage(path string) (PartitionUsage, error) {
total, err := GetPartitionSize(path)
if err != nil {
return PartitionUsage{}, err
}
used, err := GetPartitionUsed(path)
if err != nil {
return PartitionUsage{}, err
}
return PartitionUsage{Total: total, Used: used}, nil
}
// GetAggregatedPartitionUsage returns total and used bytes summed across distinct
// capacity groups mounted at or under root. See GetPartitionUsageVariants.
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
aggregate, _, err := GetPartitionUsageVariants(root)
if err != nil {
return 0, 0, err
}
return aggregate.Total, aggregate.Used, nil
}
@@ -0,0 +1,188 @@
//go:build freebsd
package fileutils
import (
"fmt"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
// bsdMount is one Getfsstat record reduced to what capacity aggregation
// needs: fstype, source, mountpoint, usage, and the mount's own fsid.
type bsdMount struct {
fstype string
source string
mountpoint string
usage rawUsage
fsid unix.Fsid
}
// resolvedMount is what a path lookup reports for a mountpoint: the
// mountpoint of the filesystem that actually owns the path and that
// filesystem's fsid.
type resolvedMount struct {
mountpoint string
fsid unix.Fsid
ok bool
}
// statfsMountpoint resolves which filesystem a mountpoint path lands on.
func statfsMountpoint(mp string) resolvedMount {
var st unix.Statfs_t
if err := unix.Statfs(mp, &st); err != nil {
return resolvedMount{}
}
return resolvedMount{
mountpoint: filepath.Clean(unix.ByteSliceToString(st.Mntonname[:])),
fsid: st.Fsid,
ok: true,
}
}
// GetPartitionUsageVariants returns the aggregate and root-only capacity views
// of root. FreeBSD has no mountinfo; Getfsstat already carries per-mount statfs
// data, so grouping happens on the result set directly (ZFS datasets grouped by
// pool name, everything else by fsid).
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
if root == "" {
return aggregate, rootOnly, fmt.Errorf("empty path")
}
root = filepath.Clean(root)
rootOnly, rootErr := singlePathPartitionUsage(root)
count, statErr := unix.Getfsstat(nil, unix.MNT_WAIT)
if statErr != nil || count <= 0 {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
stats := make([]unix.Statfs_t, count)
if _, statErr = unix.Getfsstat(stats, unix.MNT_WAIT); statErr != nil {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
var mounts []bsdMount
for i := range stats {
s := &stats[i]
avail := uint64(0)
if s.Bavail > 0 {
avail = uint64(s.Bavail)
}
mounts = append(mounts, bsdMount{
fstype: unix.ByteSliceToString(s.Fstypename[:]),
source: unix.ByteSliceToString(s.Mntfromname[:]),
mountpoint: filepath.Clean(unix.ByteSliceToString(s.Mntonname[:])),
usage: rawUsage{
total: s.Blocks * s.Bsize,
avail: avail * s.Bsize,
},
fsid: s.Fsid,
})
}
covering, nested := selectCapacityMounts(mounts, root, statfsMountpoint)
key := func(m bsdMount) string {
if isZFSType(m.fstype) {
return "zfs:" + zfsPoolName(m.source)
}
if m.fsid.Val[0] == 0 && m.fsid.Val[1] == 0 {
// fsid-less filesystems (e.g. some tmpfs mounts): keep per mountpoint.
return "mp:" + m.mountpoint
}
return fmt.Sprintf("fsid:%x:%x", m.fsid.Val[0], m.fsid.Val[1])
}
groups := make(map[string][]rawUsage)
fstypes := make(map[string]string)
add := func(m bsdMount) {
k := key(m)
fstypes[k] = m.fstype
groups[k] = append(groups[k], m.usage)
}
if covering != nil {
add(*covering)
}
for _, m := range nested {
add(m)
}
if len(groups) == 0 {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
for k, stats := range groups {
u := combineGroupUsage(stats)
if !isZFSType(fstypes[k]) && len(stats) > 1 {
// Non-shared-pool mounts dedupe to a single measurement.
u = combineGroupUsage(stats[:1])
}
aggregate.Total += u.Total
aggregate.Used += u.Used
}
return aggregate, rootOnly, rootErr
}
// selectCapacityMounts picks the mounts contributing capacity under root: the
// filesystem covering root plus mounts nested beneath it.
//
// Getfsstat retains records that path lookups no longer reach: mounts stacked
// under a later mount at the same mountpoint, and mounts below an overmounted
// ancestor (stacked records have no documented order, so position cannot
// identify the visible one). A record counts only when a lookup on its
// mountpoint still lands on it: the resolved mountpoint must equal the
// record's and, for filesystems carrying an fsid, the resolved fsid must match
// the record's (every stacked record resolves to the topmost fsid). fsid-less
// records keep the first match at a mountpoint; stacked fsid-less mounts are
// indistinguishable.
func selectCapacityMounts(mounts []bsdMount, root string, lookup func(string) resolvedMount) (*bsdMount, []bsdMount) {
var covering *bsdMount
var nested []bsdMount
claimed := make(map[string]bool)
resolved := make(map[string]resolvedMount)
for i := range mounts {
m := mounts[i]
covers := m.mountpoint == root || m.mountpoint == "/" || strings.HasPrefix(root, m.mountpoint+"/")
nests := strings.HasPrefix(m.mountpoint, root+"/")
if !covers && !nests {
continue
}
r, seen := resolved[m.mountpoint]
if !seen {
r = lookup(m.mountpoint)
resolved[m.mountpoint] = r
}
if !r.ok || r.mountpoint != m.mountpoint {
continue
}
if m.fsid.Val[0] != 0 || m.fsid.Val[1] != 0 {
if r.fsid != m.fsid {
continue
}
} else if claimed[m.mountpoint] {
continue
}
claimed[m.mountpoint] = true
if covers && (covering == nil || len(m.mountpoint) > len(covering.mountpoint)) {
c := m
covering = &c
}
if nests {
if isZFSType(m.fstype) && isZFSSnapshotMount(m.source, m.mountpoint) {
continue
}
nested = append(nested, m)
}
}
return covering, nested
}
@@ -0,0 +1,161 @@
//go:build freebsd
package fileutils
import (
"testing"
"golang.org/x/sys/unix"
)
func fsidVal(a, b int32) unix.Fsid {
return unix.Fsid{Val: [2]int32{a, b}}
}
func resolvedOK(mp string, fsid unix.Fsid) resolvedMount {
return resolvedMount{mountpoint: mp, fsid: fsid, ok: true}
}
// TestSelectCapacityMountsOvermountedAncestorHidesNested reproduces the
// reviewed layout: /srv/child was mounted first, then /srv was overmounted.
// Getfsstat still reports the unique /srv/child record, but a lookup of that
// path lands inside the covering filesystem, so its usage must be excluded.
func TestSelectCapacityMountsOvermountedAncestorHidesNested(t *testing.T) {
rootFs, childFs, coverFs := fsidVal(1, 1), fsidVal(2, 2), fsidVal(3, 3)
mounts := []bsdMount{
{fstype: "ufs", source: "/dev/da0p2", mountpoint: "/", fsid: rootFs, usage: rawUsage{total: 100, avail: 50}},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/child", fsid: childFs, usage: rawUsage{total: 200, avail: 100}},
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/srv", fsid: coverFs, usage: rawUsage{total: 300, avail: 150}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv":
return resolvedOK("/srv", coverFs)
case "/srv/child":
// Hidden: resolves to the covering filesystem at /srv.
return resolvedOK("/srv", coverFs)
}
return resolvedMount{}
}
covering, nested := selectCapacityMounts(mounts, "/srv", lookup)
if covering == nil || covering.mountpoint != "/srv" {
t.Fatalf("covering = %+v, want mountpoint /srv", covering)
}
if len(nested) != 0 {
t.Fatalf("nested = %+v, want hidden /srv/child excluded", nested)
}
}
// TestSelectCapacityMountsUnreachableNestedSkipped covers the same hidden
// layout when the covered path no longer exists in the new filesystem: the
// lookup fails outright and the stale record is dropped.
func TestSelectCapacityMountsUnreachableNestedSkipped(t *testing.T) {
rootFs, coverFs := fsidVal(1, 1), fsidVal(3, 3)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/srv", fsid: coverFs},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/gone", fsid: fsidVal(2, 2), usage: rawUsage{total: 200, avail: 100}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv":
return resolvedOK("/srv", coverFs)
}
return resolvedMount{} // ENOENT under the overmount
}
_, nested := selectCapacityMounts(mounts, "/srv", lookup)
if len(nested) != 0 {
t.Fatalf("nested = %+v, want unreachable /srv/gone excluded", nested)
}
}
// TestSelectCapacityMountsStackedTopmostOnly checks that of several records
// at one mountpoint only the one a lookup resolves to is counted.
func TestSelectCapacityMountsStackedTopmostOnly(t *testing.T) {
rootFs, lowerFs, upperFs := fsidVal(1, 1), fsidVal(4, 4), fsidVal(5, 5)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/srv/data", fsid: lowerFs, usage: rawUsage{total: 200, avail: 100}},
{fstype: "tmpfs", source: "tmpfs", mountpoint: "/srv/data", fsid: upperFs, usage: rawUsage{total: 60, avail: 30}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv/data":
return resolvedOK("/srv/data", upperFs)
}
return resolvedMount{}
}
_, nested := selectCapacityMounts(mounts, "/srv", lookup)
if len(nested) != 1 || nested[0].fsid != upperFs {
t.Fatalf("nested = %+v, want only the topmost /srv/data record", nested)
}
}
// TestSelectCapacityMountsHiddenCoveringAncestor: when the deepest ancestor
// mount of root is itself hidden by an overmount, covering must fall back to
// the deepest visible ancestor.
func TestSelectCapacityMountsHiddenCoveringAncestor(t *testing.T) {
rootFs, overFs, staleFs := fsidVal(1, 1), fsidVal(8, 8), fsidVal(9, 9)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "ufs", source: "/dev/da2p1", mountpoint: "/x", fsid: overFs},
{fstype: "ufs", source: "/dev/da1p1", mountpoint: "/x/y", fsid: staleFs, usage: rawUsage{total: 200, avail: 100}},
}
// /x was mounted over the parent chain of /x/y: lookups of /x/y resolve
// inside the /x filesystem.
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/x":
return resolvedOK("/x", overFs)
case "/x/y":
return resolvedOK("/x", overFs)
}
return resolvedMount{}
}
covering, nested := selectCapacityMounts(mounts, "/x/y/z", lookup)
if covering == nil || covering.mountpoint != "/x" {
t.Fatalf("covering = %+v, want deepest visible ancestor /x", covering)
}
if len(nested) != 0 {
t.Fatalf("nested = %+v, want none", nested)
}
}
// TestSelectCapacityMountsNestedVisibleKept is the baseline: a nested mount
// that still resolves to itself contributes its usage.
func TestSelectCapacityMountsNestedVisibleKept(t *testing.T) {
rootFs, zfsFs := fsidVal(1, 1), fsidVal(7, 7)
mounts := []bsdMount{
{fstype: "ufs", mountpoint: "/", fsid: rootFs},
{fstype: "zfs", source: "tank/data", mountpoint: "/srv/data", fsid: zfsFs, usage: rawUsage{total: 400, avail: 200}},
}
lookup := func(mp string) resolvedMount {
switch mp {
case "/":
return resolvedOK("/", rootFs)
case "/srv/data":
return resolvedOK("/srv/data", zfsFs)
}
return resolvedMount{}
}
covering, nested := selectCapacityMounts(mounts, "/srv", lookup)
if covering == nil || covering.mountpoint != "/" {
t.Fatalf("covering = %+v, want /", covering)
}
if len(nested) != 1 || nested[0].mountpoint != "/srv/data" {
t.Fatalf("nested = %+v, want [/srv/data]", nested)
}
}
@@ -7,94 +7,64 @@ import (
"os"
"path/filepath"
"syscall"
"golang.org/x/sys/unix"
)
// GetAggregatedPartitionUsage returns total and used bytes summed across distinct
// filesystems mounted at or under root (deduped by major:minor from mountinfo).
// Falls back to a single-path probe when mountinfo cannot be read.
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
// GetPartitionUsageVariants returns two capacity views of root:
// - aggregate: total/used summed across distinct capacity groups mounted at or
// under root, deduped so shared-pool filesystems (ZFS datasets, btrfs
// subvolumes) are counted once per pool.
// - rootOnly: a single statfs of root, ignoring nested mounts.
//
// Falls back to the single-path probe for both when mountinfo cannot be read.
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
if root == "" {
return 0, 0, fmt.Errorf("empty path")
return aggregate, rootOnly, fmt.Errorf("empty path")
}
root = filepath.Clean(root)
paths, parseErr := distinctMountPathsUnder(root)
if parseErr != nil || len(paths) == 0 {
return singlePathPartitionUsage(root)
rootOnly, rootErr := singlePathPartitionUsage(root)
groups, parseErr := mountGroupsUnder(root)
if parseErr != nil || len(groups) == 0 {
if rootErr != nil {
return aggregate, rootOnly, rootErr
}
return rootOnly, rootOnly, nil
}
var firstErr error
for _, path := range paths {
t, u, stErr := partitionUsageAt(path)
if stErr != nil {
if firstErr == nil {
firstErr = stErr
for _, group := range groups {
stats := make([]rawUsage, 0, len(group.paths))
for _, p := range group.paths {
stat, stErr := partitionStatfs(p)
if stErr != nil {
if firstErr == nil {
firstErr = stErr
}
continue
}
continue
stats = append(stats, stat)
}
total += t
used += u
u := combineGroupUsage(stats)
aggregate.Total += u.Total
aggregate.Used += u.Used
}
if total == 0 && used == 0 {
if aggregate.Total == 0 && aggregate.Used == 0 {
if firstErr != nil {
return 0, 0, firstErr
return aggregate, rootOnly, firstErr
}
return singlePathPartitionUsage(root)
aggregate = rootOnly
}
return total, used, nil
return aggregate, rootOnly, rootErr
}
func singlePathPartitionUsage(root string) (total, used uint64, err error) {
total, err = GetPartitionSize(root)
if err != nil {
return 0, 0, err
}
used, err = GetPartitionUsed(root)
if err != nil {
return 0, 0, err
}
return total, used, nil
}
func distinctMountPathsUnder(root string) ([]string, error) {
func mountGroupsUnder(root string) ([]mountGroup, error) {
data, err := readMountinfo()
if err != nil {
return nil, err
}
paths, err := distinctMountPathsFromMountinfo(data, root)
if err != nil {
return nil, err
}
// If mountinfo had no covering mount for root, ensure root is probed.
if len(paths) == 0 {
return []string{root}, nil
}
hasRoot := false
for _, p := range paths {
if p == root {
hasRoot = true
break
}
}
if !hasRoot {
if dev, stErr := deviceIDFromPath(root); stErr == nil {
// Avoid duplicating a device already selected under another path.
already := false
for _, p := range paths {
if d, e := deviceIDFromPath(p); e == nil && d == dev {
already = true
break
}
}
if !already {
paths = append(paths, root)
}
}
}
return paths, nil
return mountGroupsFromMountinfo(data, root)
}
func readMountinfo() (string, error) {
@@ -107,24 +77,14 @@ func readMountinfo() (string, error) {
return "", fmt.Errorf("mountinfo not available")
}
func deviceIDFromPath(path string) (string, error) {
var st unix.Stat_t
if err := unix.Stat(path, &st); err != nil {
return "", err
}
return fmt.Sprintf("%d:%d", unix.Major(uint64(st.Dev)), unix.Minor(uint64(st.Dev))), nil
}
func partitionUsageAt(path string) (total, used uint64, err error) {
func partitionStatfs(path string) (rawUsage, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, 0, err
return rawUsage{}, err
}
bsize := blockSize(&stat)
total = uint64(stat.Blocks) * bsize
free := uint64(stat.Bavail) * bsize
if total >= free {
used = total - free
}
return total, used, nil
return rawUsage{
total: uint64(stat.Blocks) * bsize,
avail: uint64(stat.Bavail) * bsize,
}, nil
}
@@ -12,55 +12,111 @@ import (
type mountEntry struct {
deviceID string
mountpoint string
fstype string
source string
}
// distinctMountPathsFromMountinfo selects one path per distinct filesystem that
// contributes capacity for root: the FS that owns root, plus nested mounts under
// root. Deduped by major:minor from mountinfo.
func distinctMountPathsFromMountinfo(mountinfo string, root string) ([]string, error) {
// mountGroup is a set of mountpoints that share one capacity pool. Groups keyed
// by filesystem identity get one probe path; shared-pool filesystems (zfs) keep
// every dataset mountpoint so per-dataset used space can be summed.
type mountGroup struct {
key string
fstype string
paths []string
}
// capacityKey identifies mounts that share the same underlying capacity.
// - zfs: every dataset has its own device ID but draws from the pool; group by pool.
// - btrfs: subvolume mounts have distinct device IDs but report the whole
// filesystem; group by mount source.
// - everything else: device major:minor, as before.
func (e *mountEntry) capacityKey() string {
switch e.fstype {
case "zfs", "zfsfuse":
return "zfs:" + zfsPoolName(e.source)
case "btrfs":
return "btrfs:" + e.source
default:
return "dev:" + e.deviceID
}
}
// mountGroupsFromMountinfo selects the mounts contributing capacity for root:
// the filesystem that owns root, plus nested mounts under root, grouped by
// capacity key. ZFS snapshot mounts are excluded.
func mountGroupsFromMountinfo(mountinfo string, root string) ([]mountGroup, error) {
root = path.Clean(root)
entries, err := parseAllMounts(mountinfo)
if err != nil {
return nil, err
}
seen := make(map[string]string)
groupIndex := make(map[string]int)
var groups []mountGroup
addPath := func(e *mountEntry, p string) {
key := e.capacityKey()
i, ok := groupIndex[key]
if !ok {
i = len(groups)
groupIndex[key] = i
groups = append(groups, mountGroup{key: key, fstype: e.fstype})
}
// Only shared-pool filesystems (zfs) need every mountpoint; other groups
// report identical statfs per mount, so one probe path suffices.
if !isZFSType(groups[i].fstype) && len(groups[i].paths) > 0 {
return
}
for _, existing := range groups[i].paths {
if existing == p {
return
}
}
groups[i].paths = append(groups[i].paths, p)
}
var covering *mountEntry
for i := range entries {
e := &entries[i]
mp := e.mountpoint
if mp == root || strings.HasPrefix(root, mp+"/") || mp == "/" {
if covering == nil || len(mp) > len(covering.mountpoint) {
// >= keeps the later entry: stacked mounts at one mountpoint are
// shadowed by the topmost (last in mountinfo order).
if covering == nil || len(mp) >= len(covering.mountpoint) {
covering = e
}
}
}
if covering != nil {
seen[covering.deviceID] = root
// Probe root itself for the covering filesystem: root may be a plain
// directory inside a mount whose mountpoint is an ancestor.
addPath(covering, root)
} else {
// No mount covers root (e.g. hidden bind mount); probe it directly so
// it is not dropped when nested mounts under root do exist.
groups = append(groups, mountGroup{key: "self:" + root, paths: []string{root}})
}
for _, e := range entries {
// Stacked mounts: only the topmost (last in mountinfo order) is reachable.
lastAtMountpoint := make(map[string]int)
for i := range entries {
lastAtMountpoint[entries[i].mountpoint] = i
}
for i := range entries {
e := &entries[i]
mp := e.mountpoint
if mp != root && !strings.HasPrefix(mp, root+"/") {
continue
}
if _, ok := seen[e.deviceID]; ok {
if lastAtMountpoint[mp] != i {
continue
}
seen[e.deviceID] = mp
if isZFSType(e.fstype) && isZFSSnapshotMount(e.source, mp) {
continue
}
addPath(e, mp)
}
out := make([]string, 0, len(seen))
uniq := make(map[string]struct{}, len(seen))
for _, p := range seen {
if _, ok := uniq[p]; ok {
continue
}
uniq[p] = struct{}{}
out = append(out, p)
}
return out, nil
return groups, nil
}
func parseAllMounts(mountinfo string) ([]mountEntry, error) {
@@ -88,9 +144,16 @@ func parseMountinfoLine(line string) (mountEntry, bool) {
if len(fields) < 5 {
return mountEntry{}, false
}
// After " - ": "fstype source super-options".
post := strings.Fields(parts[1])
if len(post) < 2 {
return mountEntry{}, false
}
return mountEntry{
deviceID: fields[2],
mountpoint: unescapeFstab(fields[4]),
fstype: post[0],
source: unescapeFstab(post[1]),
}, true
}
@@ -7,6 +7,25 @@ import (
"testing"
)
// allGroupPaths flattens groups into a sorted list of probe paths.
func allGroupPaths(groups []mountGroup) []string {
var out []string
for _, g := range groups {
out = append(out, g.paths...)
}
sort.Strings(out)
return out
}
func findGroup(groups []mountGroup, prefix string) *mountGroup {
for i := range groups {
if len(groups[i].key) >= len(prefix) && groups[i].key[:len(prefix)] == prefix {
return &groups[i]
}
}
return nil
}
func TestDistinctMountPathsNestedDisk(t *testing.T) {
// /srv lives on rootfs 8:1; /srv/data is a separate 2TB disk 8:16.
mountinfo := "" +
@@ -14,11 +33,11 @@ func TestDistinctMountPathsNestedDisk(t *testing.T) {
"45 22 8:16 /data /srv/data rw - btrfs /dev/sdb1 rw\n" +
"50 22 0:47 / /proc rw - proc proc rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
paths := allGroupPaths(groups)
want := []string{"/srv", "/srv/data"}
if len(paths) != len(want) {
t.Fatalf("paths = %v, want %v", paths, want)
@@ -37,11 +56,11 @@ func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
"46 45 0:47 / /srv/data rw - tmpfs tmpfs rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
paths := allGroupPaths(groups)
want := []string{"/srv", "/srv/data"}
if len(paths) != len(want) {
t.Fatalf("paths = %v, want %v (no duplicate probe paths)", paths, want)
@@ -56,15 +75,15 @@ func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
func TestDistinctMountPathsBindSameDeviceNotDuplicated(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"45 22 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
"46 22 8:16 / /srv/data/bind rw,bind - btrfs /dev/sdb1 rw\n"
"45 22 8:16 / /srv/data rw - ext4 /dev/sdb1 rw\n" +
"46 22 8:16 / /srv/data/bind rw,bind - ext4 /dev/sdb1 rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
if len(paths) != 2 {
t.Fatalf("expected 2 distinct devices, got %v", paths)
if len(groups) != 2 {
t.Fatalf("expected 2 distinct capacity groups, got %v", groups)
}
}
@@ -74,11 +93,11 @@ func TestDistinctMountPathsSourceIsOwnMount(t *testing.T) {
"40 22 8:2 / /srv rw - ext4 /dev/sda2 rw\n" +
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
paths := allGroupPaths(groups)
want := []string{"/srv", "/srv/data"}
if len(paths) != 2 || paths[0] != want[0] || paths[1] != want[1] {
t.Fatalf("paths = %v, want %v", paths, want)
@@ -90,10 +109,11 @@ func TestDistinctMountPathsIgnoresUnrelated(t *testing.T) {
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"30 22 8:3 / /home rw - ext4 /dev/sda3 rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
paths := allGroupPaths(groups)
if len(paths) != 1 || paths[0] != "/srv" {
t.Fatalf("paths = %v, want [/srv]", paths)
}
@@ -110,4 +130,124 @@ func TestParseMountinfoEscapedSpace(t *testing.T) {
if entry.deviceID != "98:0" {
t.Fatalf("deviceID = %q, want 98:0", entry.deviceID)
}
if entry.fstype != "ext3" {
t.Fatalf("fstype = %q, want ext3", entry.fstype)
}
if entry.source != "/dev/root" {
t.Fatalf("source = %q, want /dev/root", entry.source)
}
}
// TestMountGroupsZFSPoolDatasetsGrouped reproduces issue #3025: every ZFS
// dataset gets its own device ID, so major:minor dedup counts pool capacity
// once per dataset. They must share one capacity group.
func TestMountGroupsZFSPoolDatasetsGrouped(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 0:55 / /srv/pool rw - zfs pool rw\n" +
"41 40 0:56 / /srv/pool/ds01 rw - zfs pool/ds01 rw\n" +
"42 41 0:57 / /srv/pool/ds01/a rw - zfs pool/ds01/a rw\n" +
"43 40 0:58 / /srv/pool/ds02 rw - zfs pool/ds02 rw\n" +
"44 22 0:59 / /other/pool2 rw - zfs pool2 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv/pool")
if err != nil {
t.Fatal(err)
}
g := findGroup(groups, "zfs:pool")
if g == nil || g.key != "zfs:pool" {
t.Fatalf("expected one zfs:pool group, got %v", groups)
}
want := []string{"/srv/pool", "/srv/pool/ds01", "/srv/pool/ds01/a", "/srv/pool/ds02"}
got := append([]string{}, g.paths...)
sort.Strings(got)
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("zfs group paths = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("zfs group paths = %v, want %v", got, want)
}
}
// The unrelated second pool must not join the group.
if g2 := findGroup(groups, "zfs:pool2"); g2 != nil {
t.Fatalf("unrelated pool2 should not be included: %v", groups)
}
}
func TestMountGroupsZFSSnapshotsSkipped(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 0:55 / /srv/pool rw - zfs pool rw\n" +
"41 40 0:56 / /srv/pool/ds01 rw - zfs pool/ds01 rw\n" +
"42 41 0:57 / /srv/pool/ds01/.zfs/snapshot/snap1 rw - zfs pool/ds01@snap1 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv/pool")
if err != nil {
t.Fatal(err)
}
paths := allGroupPaths(groups)
for _, p := range paths {
if p == "/srv/pool/ds01/.zfs/snapshot/snap1" {
t.Fatalf("snapshot mount should be skipped: %v", paths)
}
}
if len(paths) != 2 {
t.Fatalf("paths = %v, want 2 live datasets", paths)
}
}
func TestMountGroupsBtrfsSubvolumesDeduped(t *testing.T) {
// Each btrfs subvolume mount has a distinct anonymous device but reports
// whole-filesystem statfs; they must collapse into one group.
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 0:33 /@ /srv rw - btrfs /dev/sdb1 rw\n" +
"41 40 0:34 /@home /srv/home rw - btrfs /dev/sdb1 rw\n" +
"42 40 0:35 /@data /srv/data rw - btrfs /dev/sdb1 rw\n"
groups, err := mountGroupsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
g := findGroup(groups, "btrfs:")
if g == nil || g.key != "btrfs:/dev/sdb1" {
t.Fatalf("expected one btrfs group, got %v", groups)
}
// Subvolume mounts report identical fs-wide statfs; one probe path suffices.
if len(g.paths) != 1 || g.paths[0] != "/srv" {
t.Fatalf("btrfs group paths = %v, want [/srv]", g.paths)
}
if len(groups) != 1 {
t.Fatalf("expected exactly 1 group for /srv, got %v", groups)
}
}
// TestCombineGroupUsageZFSPool verifies the arithmetic from #3025: 26 datasets
// each reporting ~19.7T avail must yield used+19.7T total, not 26*19.7T+used.
func TestCombineGroupUsageZFSPool(t *testing.T) {
const avail = uint64(19_700_000_000_000) // ~19.7 TiB pool free
stats := []rawUsage{{total: 20_000_000_000_000, avail: avail}} // root ds: 0.3T used
for i := 0; i < 25; i++ {
stats = append(stats, rawUsage{total: avail + 100_000_000_000, avail: avail})
}
// One quota'd child: reports a smaller avail; must not shrink the pool.
stats = append(stats, rawUsage{total: 1_000_000_000_000, avail: 500_000_000_000})
u := combineGroupUsage(stats)
wantUsed := uint64(300_000_000_000) + 25*uint64(100_000_000_000) + 500_000_000_000
if u.Used != wantUsed {
t.Fatalf("used = %d, want %d", u.Used, wantUsed)
}
if u.Total != wantUsed+avail {
t.Fatalf("total = %d, want %d (used+single pool avail)", u.Total, wantUsed+avail)
}
}
func TestCombineGroupUsageSingleMountIdentity(t *testing.T) {
u := combineGroupUsage([]rawUsage{{total: 1000, avail: 400}})
if u.Total != 1000 || u.Used != 600 {
t.Fatalf("single mount = %+v, want total 1000 used 600", u)
}
}
@@ -1,17 +1,14 @@
//go:build !linux
//go:build !linux && !freebsd
package fileutils
// GetAggregatedPartitionUsage returns total and used bytes for the filesystem
// that owns root. Non-Linux builds use a single statfs/GetDiskFreeSpaceEx path.
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
total, err = GetPartitionSize(root)
// GetPartitionUsageVariants returns the aggregate and root-only capacity views
// of root. Without mount info only a single statfs of the source path is
// available, so both variants are identical.
func GetPartitionUsageVariants(root string) (aggregate, rootOnly PartitionUsage, err error) {
u, err := singlePathPartitionUsage(root)
if err != nil {
return 0, 0, err
return aggregate, rootOnly, err
}
used, err = GetPartitionUsed(root)
if err != nil {
return 0, 0, err
}
return total, used, nil
return u, u, nil
}
@@ -0,0 +1,50 @@
//go:build linux || freebsd
package fileutils
import "strings"
// rawUsage is a single filesystem measurement in bytes.
type rawUsage struct {
total uint64
avail uint64
}
// combineGroupUsage folds statfs results for mounts that share one capacity pool
// into a single total/used pair. Per-mount used (total-avail) is summed, while
// shared free space is counted once via the largest reported avail. This keeps
// ZFS datasets or btrfs subvolumes from multiplying the pool's capacity by the
// number of mounts under a source.
func combineGroupUsage(stats []rawUsage) PartitionUsage {
var used, avail uint64
for _, s := range stats {
if s.total > s.avail {
used += s.total - s.avail
}
if s.avail > avail {
avail = s.avail
}
}
return PartitionUsage{Total: used + avail, Used: used}
}
// isZFSType reports whether fstype is a ZFS variant whose datasets share pool
// capacity across mounts (kernel zfs or zfsfuse).
func isZFSType(fstype string) bool {
return fstype == "zfs" || fstype == "zfsfuse"
}
// zfsPoolName returns the pool component of a ZFS mount source ("pool/ds@snap" -> "pool").
func zfsPoolName(source string) string {
if i := strings.IndexAny(source, "/@"); i >= 0 {
return source[:i]
}
return source
}
// isZFSSnapshotMount reports whether a ZFS mount is a snapshot (.zfs snapdir or
// an "@snapshot" source). Snapshots share pool space with the live datasets and
// are excluded from capacity aggregation.
func isZFSSnapshotMount(source, mountpoint string) bool {
return strings.Contains(source, "@") || strings.Contains(mountpoint, "/.zfs/")
}
+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)
}
})
}
}
+213
View File
@@ -0,0 +1,213 @@
// Package archiveencoding decodes ZIP entry names before path normalization.
package archiveencoding
import (
"archive/zip"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
)
// ErrEncoding indicates a filename encoding that needs a different user selection.
var ErrEncoding = errors.New("invalid ZIP filename encoding")
type codec struct {
name string
encoding encoding.Encoding
}
var codecs = []codec{
{"utf-8", nil},
{"cp932", japanese.ShiftJIS},
{"gb18030", simplifiedchinese.GB18030},
{"big5", traditionalchinese.Big5},
{"euc-kr", korean.EUCKR},
{"cp437", charmap.CodePage437},
{"windows-1252", charmap.Windows1252},
}
// Candidate contains a bounded preview, decoded exactly as during extraction.
type Candidate struct {
Encoding string `json:"encoding"`
Names []string `json:"names"`
}
// Preview describes compatible encodings. An empty suggestion requires a choice;
// a heuristic suggestion is never a guarantee that the names are correct.
type Preview struct {
Suggested string `json:"suggested"`
Candidates []Candidate `json:"candidates"`
}
// unicodeName prefers the UTF-8 flag, then a verified Info-ZIP Unicode Path field.
// A Unicode Path field is usable only when its version and original-name CRC match.
func unicodeName(f *zip.File) (string, bool) {
if f.Flags&0x800 != 0 {
return f.Name, true
}
extra := f.Extra
for len(extra) >= 4 {
tag := binary.LittleEndian.Uint16(extra)
size := int(binary.LittleEndian.Uint16(extra[2:]))
extra = extra[4:]
if size > len(extra) {
break
}
field := extra[:size]
extra = extra[size:]
if tag == 0x7075 && len(field) > 5 && field[0] == 1 &&
binary.LittleEndian.Uint32(field[1:5]) == crc32.ChecksumIEEE([]byte(f.Name)) && utf8.Valid(field[5:]) {
return string(field[5:]), true
}
}
return "", false
}
func decode(f *zip.File, c codec) (string, error) {
name, declared := unicodeName(f)
if !declared {
name = f.Name
if c.encoding != nil {
var err error
name, err = c.encoding.NewDecoder().String(name)
if err != nil || strings.ContainsRune(name, utf8.RuneError) {
return "", fmt.Errorf("%w: cannot decode filename as %s", ErrEncoding, c.name)
}
}
}
if !utf8.ValidString(name) || strings.ContainsRune(name, 0) {
return "", fmt.Errorf("%w: invalid filename for %s", ErrEncoding, c.name)
}
return name, nil
}
// Inspect considers all entry names without decompressing file contents. Candidate
// previews prefer names lacking Unicode metadata so that users can compare them.
func Inspect(files []*zip.File) Preview {
result := Preview{Candidates: []Candidate{}}
bestScore, tied := 0, false
for _, c := range codecs {
candidate := Candidate{Encoding: c.name, Names: []string{}}
fallback := []string{}
score, valid := 0, true
for _, f := range files {
name, err := decode(f, c)
if err != nil {
valid = false
break
}
if len(fallback) < 5 {
fallback = append(fallback, name)
}
if _, declared := unicodeName(f); !declared && !isASCII(f.Name) {
if len(candidate.Names) < 5 {
candidate.Names = append(candidate.Names, name)
}
// The EUC-KR decoder also accepts CP949 extension bytes, including
// CP932 kana pairs. Do not use those bytes as Korean evidence.
if c.name != "euc-kr" || isEUCKR(f.Name) {
score += scriptEvidence(name, c.name)
}
}
}
if !valid {
continue
}
if len(candidate.Names) == 0 {
candidate.Names = fallback
}
result.Candidates = append(result.Candidates, candidate)
// Valid UTF-8 (including metadata-backed names) needs no legacy guess.
if c.name == "utf-8" {
result.Suggested = c.name
}
if score > bestScore {
bestScore, tied = score, false
if result.Suggested != "utf-8" {
result.Suggested = c.name
}
} else if score == bestScore && score > 0 {
tied = true
}
}
if result.Suggested != "utf-8" && (bestScore < 3 || tied) {
result.Suggested = ""
}
return result
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
return false
}
}
return true
}
// isEUCKR excludes CP949 extension sequences from automatic Korean suggestions.
func isEUCKR(raw string) bool {
for i := 0; i < len(raw); i++ {
if raw[i] < utf8.RuneSelf {
continue
}
if raw[i] < 0xa1 || raw[i] > 0xfe || i+1 >= len(raw) || raw[i+1] < 0xa1 || raw[i+1] > 0xfe {
return false
}
i++
}
return true
}
// Kana and Hangul give useful script evidence; Han alone does not distinguish
// Japanese, simplified Chinese and traditional Chinese. Half-width kana are not
// evidence: unrelated legacy bytes frequently decode to half-width kana.
func scriptEvidence(name, charset string) int {
score := 0
for _, r := range name {
if charset == "cp932" && r < 0xff00 && (unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r)) {
score++
}
if charset == "euc-kr" && unicode.Is(unicode.Hangul, r) {
score++
}
}
return score
}
// DecodeNames resolves the selection once for the entire archive. Explicit
// Unicode metadata always wins over the selected fallback encoding.
func DecodeNames(files []*zip.File, charset string) ([]string, error) {
if charset == "" {
charset = Inspect(files).Suggested
if charset == "" {
return nil, fmt.Errorf("%w: choose an encoding before extracting", ErrEncoding)
}
}
for _, c := range codecs {
if c.name != charset {
continue
}
names := make([]string, 0, len(files))
for _, f := range files {
name, err := decode(f, c)
if err != nil {
return nil, err
}
names = append(names, name)
}
return names, nil
}
return nil, fmt.Errorf("%w: unsupported encoding %s", ErrEncoding, charset)
}
@@ -0,0 +1,119 @@
package archiveencoding
import (
"archive/zip"
"encoding/binary"
"golang.org/x/text/encoding/japanese"
"hash/crc32"
"reflect"
"testing"
)
func entry(name string) *zip.File { return &zip.File{FileHeader: zip.FileHeader{Name: name}} }
func cp932(t *testing.T, name string) string {
t.Helper()
s, err := japanese.ShiftJIS.NewEncoder().String(name)
if err != nil {
t.Fatal(err)
}
return s
}
func TestJapaneseSuggestionMatchesDecoding(t *testing.T) {
t.Parallel()
want := []string{"日本語/テストソ001.txt", "日本語/テストソ002.txt"}
files := []*zip.File{entry(cp932(t, want[0])), entry(cp932(t, want[1]))}
preview := Inspect(files)
if preview.Suggested != "cp932" {
t.Fatalf("suggestion: %+v", preview)
}
names, err := DecodeNames(files, preview.Suggested)
if err != nil || !reflect.DeepEqual(names, want) {
t.Fatalf("names=%q err=%v", names, err)
}
for _, c := range preview.Candidates {
if c.Encoding == preview.Suggested && !reflect.DeepEqual(c.Names, names) {
t.Fatalf("preview mismatch: %q", c.Names)
}
}
}
func TestAmbiguousNamesRequireSelection(t *testing.T) {
t.Parallel()
files := []*zip.File{entry("caf\x82.txt")}
if p := Inspect(files); p.Suggested != "" {
t.Fatalf("ambiguous name was guessed: %+v", p)
}
if _, err := DecodeNames(files, ""); err == nil {
t.Fatal("expected explicit choice")
}
names, err := DecodeNames(files, "cp437")
if err != nil || names[0] != "café.txt" {
t.Fatalf("names=%q err=%v", names, err)
}
for _, c := range []string{"utf-8", "unknown"} {
if _, err = DecodeNames(files, c); err == nil {
t.Fatalf("invalid encoding accepted: %s", c)
}
}
}
func unicodeExtra(raw, name string) []byte {
payload := append([]byte{1, 0, 0, 0, 0}, []byte(name)...)
binary.LittleEndian.PutUint32(payload[1:], crc32.ChecksumIEEE([]byte(raw)))
extra := []byte{0x75, 0x70, 0, 0}
binary.LittleEndian.PutUint16(extra[2:], uint16(len(payload)))
return append(extra, payload...)
}
func TestUnicodeMetadataTakesPrecedence(t *testing.T) {
t.Parallel()
flagged := entry("日本語.txt")
flagged.Flags = 0x800
extra := entry("legacy.txt")
extra.Extra = unicodeExtra(extra.Name, "別の名前.txt")
names, err := DecodeNames([]*zip.File{flagged, extra}, "cp437")
if err != nil || !reflect.DeepEqual(names, []string{"日本語.txt", "別の名前.txt"}) {
t.Fatalf("names=%q err=%v", names, err)
}
extra.Extra[5] ^= 1
names, err = DecodeNames([]*zip.File{extra}, "cp437")
if err != nil || names[0] != "legacy.txt" {
t.Fatalf("names=%q err=%v", names, err)
}
}
func TestInvalidUnicodeMetadata(t *testing.T) {
t.Parallel()
for _, raw := range []string{"bad\xff.txt", "bad\x00.txt"} {
f := entry(raw)
f.Flags = 0x800
if _, err := DecodeNames([]*zip.File{f}, "cp932"); err == nil {
t.Fatalf("invalid declared UTF-8 accepted: %q", raw)
}
}
f := entry("ok.txt")
f.Extra = []byte{0x75, 0x70, 0xff, 0xff}
if _, err := DecodeNames([]*zip.File{f}, "utf-8"); err != nil {
t.Fatal(err)
}
}
func TestUTF8AndASCIIArchives(t *testing.T) {
t.Parallel()
for _, files := range [][]*zip.File{nil, {entry("readme.txt")}, {entry("日本語.txt")}} {
if p := Inspect(files); p.Suggested != "utf-8" {
t.Fatalf("expected UTF-8: %+v", p)
}
}
}
func TestPreviewBoundAndAllNamesValidated(t *testing.T) {
t.Parallel()
files := make([]*zip.File, 20)
for i := range files {
files[i] = entry("readme.txt")
}
files = append(files, entry("invalid\xff.txt"))
for _, c := range Inspect(files).Candidates {
if len(c.Names) > 5 {
t.Fatal("unbounded preview")
}
if c.Encoding == "utf-8" {
t.Fatal("entry beyond preview was not validated")
}
}
}
+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)
}
+1 -1
View File
@@ -14,5 +14,5 @@ type JSONAuth struct {
// Auth authenticates the user via a json in content body (legacy method for compatibility).
func (auther JSONAuth) Auth(r *http.Request, _ *users.Storage) (*users.User, error) {
return AuthenticatePassword(r, auther.ReCaptcha)
return AuthenticatePassword(r, auther.ReCaptcha, false)
}
+7 -3
View File
@@ -30,7 +30,8 @@ func SetDefault(s *Service) {
}
// AuthenticatePassword authenticates the user via password in request headers.
func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users.User, error) {
// When skipRequirePasswordChange is true, a valid password is accepted even if RequirePasswordChange is set (change-required flow).
func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool, skipRequirePasswordChange bool) (*users.User, error) {
if s == nil || s.users == nil {
return nil, fmt.Errorf("auth service not configured")
}
@@ -63,6 +64,9 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
if getErr != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
if user.RequirePasswordChange && user.LoginMethod == users.LoginMethodPassword && !skipRequirePasswordChange {
return nil, errors.ErrPasswordChangeRequired
}
if user.TOTPSecret != "" && !disableOtp {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
@@ -80,9 +84,9 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
}
// AuthenticatePassword authenticates via the default service.
func AuthenticatePassword(r *http.Request, disableOtp bool) (*users.User, error) {
func AuthenticatePassword(r *http.Request, disableOtp bool, skipRequirePasswordChange bool) (*users.User, error) {
if defaultService != nil {
return defaultService.AuthenticatePassword(r, disableOtp)
return defaultService.AuthenticatePassword(r, disableOtp, skipRequirePasswordChange)
}
return nil, fmt.Errorf("auth service not configured")
}
@@ -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)
}
})
}
+318 -148
View File
@@ -1,10 +1,8 @@
package access
import (
"encoding/json"
"fmt"
"maps"
"path/filepath"
"slices"
"sort"
"strings"
@@ -126,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
@@ -222,8 +223,14 @@ func accessRuleHasPayload(rule *AccessRule) bool {
// persistRuleSQLNL upserts or deletes one access_rules row to match in-memory state.
// Caller must hold s.mux (write lock). normalizedPath must match rule map keys (see getOrCreateRuleNL).
func (s *Storage) persistRuleSQLNL(sourcePath, normalizedPath string) {
if err := s.persistRuleSQLErrorNL(sourcePath, normalizedPath); err != nil {
logger.Warningf("%v", err)
}
}
func (s *Storage) persistRuleSQLErrorNL(sourcePath, normalizedPath string) error {
if s.sqlStore == nil {
return
return nil
}
var rule *AccessRule
if bySrc, ok := s.AllRules[sourcePath]; ok {
@@ -231,18 +238,35 @@ func (s *Storage) persistRuleSQLNL(sourcePath, normalizedPath string) {
}
if !accessRuleHasPayload(rule) {
if err := s.sqlStore.DeleteAccessRule(sourcePath, normalizedPath); err != nil {
logger.Warningf("access rules SQL delete (source=%s path=%s): %v", sourcePath, normalizedPath, err)
return fmt.Errorf("access rules SQL delete (source=%s path=%s): %w", sourcePath, normalizedPath, err)
}
return
return nil
}
if err := s.sqlStore.SaveAccessRule(sourcePath, normalizedPath, rule); err != nil {
logger.Warningf("access rules SQL save (source=%s path=%s): %v", sourcePath, normalizedPath, err)
return fmt.Errorf("access rules SQL save (source=%s path=%s): %w", sourcePath, normalizedPath, err)
}
return nil
}
func (s *Storage) restoreRuleSQLNL(key RuleKey, rule *AccessRule) error {
if s.sqlStore == nil {
return nil
}
if !accessRuleHasPayload(rule) {
if err := s.sqlStore.DeleteAccessRule(key.Source, key.Path); err != nil {
return fmt.Errorf("access rules SQL delete (source=%s path=%s): %w", key.Source, key.Path, err)
}
return nil
}
if err := s.sqlStore.SaveAccessRule(key.Source, key.Path, rule); err != nil {
return fmt.Errorf("access rules SQL save (source=%s path=%s): %w", key.Source, key.Path, err)
}
return nil
}
// 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).
@@ -255,6 +279,7 @@ func (s *Storage) RemoveRuleByPathKey(sourcePath, pathKey string) {
return
}
pathKey = strings.ToLower(pathKey)
if _, exists := rulesBySource[pathKey]; !exists {
return
}
@@ -267,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)
}
}
@@ -276,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)
}
@@ -287,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
}
@@ -302,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
}
@@ -317,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
}
@@ -335,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
}
@@ -353,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
}
@@ -367,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
@@ -446,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
}
@@ -505,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,
@@ -523,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
}
@@ -570,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{
@@ -677,13 +715,8 @@ func (s *Storage) DeleteGroup(group string) error {
s.mux.Lock()
defer s.mux.Unlock()
previousMembers, existed := s.Groups[group]
// Snapshot the rules so they can be restored if the transaction fails.
rulesSnapshot, err := json.Marshal(s.AllRules)
if err != nil {
return fmt.Errorf("failed to snapshot access rules: %w", err)
}
delete(s.Groups, group)
touched := s.removeAllRulesForGroupNL(group)
touched, originals := s.removeAllRulesForGroupNL(group)
if !existed && len(touched) == 0 {
return nil
}
@@ -691,11 +724,11 @@ func (s *Storage) DeleteGroup(group string) error {
if existed {
s.Groups[group] = previousMembers
}
restored := make(SourceRuleMap)
if jsonErr := json.Unmarshal(rulesSnapshot, &restored); jsonErr != nil {
logger.Errorf("failed to restore access rules after group delete error: %v", jsonErr)
} else {
s.AllRules = restored
for key, rule := range originals {
if s.AllRules[key.Source] == nil {
s.AllRules[key.Source] = make(RuleMap)
}
s.AllRules[key.Source][key.Path] = rule
}
return err
}
@@ -748,7 +781,7 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
// Create a set of new groups for efficient lookup
newGroupsSet := make(StringSet, len(newGroups))
for _, g := range newGroups {
if g == "" {
if !IsUsableSyncedGroupName(g) {
continue
}
newGroupsSet[g] = struct{}{}
@@ -862,11 +895,148 @@ func (s *Storage) RemoveUserFromGroup(group, username string) error {
return nil
}
// RemoveUserFromAllGroups removes a username from every group's member list.
// Group rows themselves are kept, even when left empty. Used when a user is deleted.
func (s *Storage) RemoveUserFromAllGroups(username string) error {
s.mux.Lock()
defer s.mux.Unlock()
affected := make(map[string]StringSet)
for group, members := range s.Groups {
if _, ok := members[username]; ok {
affected[group] = cloneStringSet(members)
}
}
if len(affected) == 0 {
return nil
}
for group := range affected {
delete(s.Groups[group], username)
}
var persisted []string
for _, group := range slices.Sorted(maps.Keys(affected)) {
if err := s.persistGroupSQLNL(group); err != nil {
for _, g := range persisted {
if restoreErr := s.restoreGroupSQLNL(g, affected[g]); restoreErr != nil {
logger.Errorf("failed to restore group %q in sql after failure: %v", g, restoreErr)
}
}
for g, snap := range affected {
s.Groups[g] = snap
}
return err
}
persisted = append(persisted, group)
}
s.clearAllCaches()
return nil
}
// RenameUserInGroups replaces one username with another in every group member list.
// Used when a user's username changes so memberships follow the rename.
func (s *Storage) RenameUserInGroups(oldName, newName string) error {
s.mux.Lock()
defer s.mux.Unlock()
if oldName == newName {
return nil
}
affected := make(map[string]StringSet)
for group, members := range s.Groups {
if _, ok := members[oldName]; ok {
affected[group] = cloneStringSet(members)
}
}
if len(affected) == 0 {
return nil
}
for group := range affected {
delete(s.Groups[group], oldName)
s.Groups[group][newName] = struct{}{}
}
var persisted []string
for _, group := range slices.Sorted(maps.Keys(affected)) {
if err := s.persistGroupSQLNL(group); err != nil {
for _, g := range persisted {
if restoreErr := s.restoreGroupSQLNL(g, affected[g]); restoreErr != nil {
logger.Errorf("failed to restore group %q in sql after failure: %v", g, restoreErr)
}
}
for g, snap := range affected {
s.Groups[g] = snap
}
return err
}
persisted = append(persisted, group)
}
s.clearAllCaches()
return nil
}
// RenameUserInRules replaces one username with another in every allow and deny user list.
// Used when a user's username changes so path access rules follow the rename.
func (s *Storage) RenameUserInRules(oldName, newName string) error {
s.mux.Lock()
defer s.mux.Unlock()
if oldName == newName {
return nil
}
affected := make(map[RuleKey]*AccessRule)
var keys []RuleKey
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
_, inAllow := rule.Allow.Users[oldName]
_, inDeny := rule.Deny.Users[oldName]
if !inAllow && !inDeny {
continue
}
key := RuleKey{Source: sourcePath, Path: indexPath}
affected[key] = cloneAccessRule(rule)
if inAllow {
delete(rule.Allow.Users, oldName)
rule.Allow.Users[newName] = struct{}{}
}
if inDeny {
delete(rule.Deny.Users, oldName)
rule.Deny.Users[newName] = struct{}{}
}
keys = append(keys, key)
}
}
if len(keys) == 0 {
return nil
}
slices.SortFunc(keys, func(a, b RuleKey) int {
if c := strings.Compare(a.Source, b.Source); c != 0 {
return c
}
return strings.Compare(a.Path, b.Path)
})
var persisted []RuleKey
for _, key := range keys {
if err := s.persistRuleSQLErrorNL(key.Source, key.Path); err != nil {
for _, pk := range persisted {
if restoreErr := s.restoreRuleSQLNL(pk, affected[pk]); restoreErr != nil {
logger.Errorf("failed to restore access rule %q/%q in sql after failure: %v", pk.Source, pk.Path, restoreErr)
}
}
for key, snap := range affected {
if s.AllRules[key.Source] == nil {
s.AllRules[key.Source] = make(RuleMap)
}
s.AllRules[key.Source][key.Path] = snap
}
return err
}
persisted = append(persisted, key)
}
s.clearAllCaches()
return nil
}
// RemoveAllowUser removes a user from the allow list for a given source and index path.
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
@@ -877,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()
@@ -895,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
@@ -906,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()
@@ -924,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
@@ -935,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()
@@ -953,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
@@ -964,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()
@@ -982,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
@@ -993,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()
@@ -1008,93 +1163,98 @@ func (s *Storage) RemoveDenyAll(sourcePath string, indexPath utils.IndexPath) (b
}
// RemoveAllRulesForUser removes a user from all allow and deny lists.
// On a persistence failure the in-memory and already-persisted rules are restored and the error is returned.
func (s *Storage) RemoveAllRulesForUser(username string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
dirty := make(map[string]map[string]struct{})
touch := func(sp, ip string) {
if dirty[sp] == nil {
dirty[sp] = make(map[string]struct{})
}
dirty[sp][ip] = struct{}{}
}
affected := make(map[RuleKey]*AccessRule)
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
if _, exists := rule.Allow.Users[username]; exists {
delete(rule.Allow.Users, username)
touch(sourcePath, indexPath)
changed = true
_, inAllow := rule.Allow.Users[username]
_, inDeny := rule.Deny.Users[username]
if !inAllow && !inDeny {
continue
}
if _, exists := rule.Deny.Users[username]; exists {
delete(rule.Deny.Users, username)
touch(sourcePath, indexPath)
changed = true
}
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], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
affected[RuleKey{Source: sourcePath, Path: indexPath}] = cloneAccessRule(rule)
delete(rule.Allow.Users, username)
delete(rule.Deny.Users, username)
}
}
if len(affected) == 0 {
return nil
}
keys := slices.SortedFunc(maps.Keys(affected), func(a, b RuleKey) int {
if c := strings.Compare(a.Source, b.Source); c != 0 {
return c
}
return strings.Compare(a.Path, b.Path)
})
for _, key := range keys {
rule := s.AllRules[key.Source][key.Path]
if rule.DenyAll || len(rule.Allow.Users) > 0 || len(rule.Allow.Groups) > 0 || len(rule.Deny.Users) > 0 || len(rule.Deny.Groups) > 0 {
continue
}
s.deleteStoredRuleNL(key.Source, key.Path)
}
var persisted []RuleKey
for _, key := range keys {
if err := s.persistRuleSQLErrorNL(key.Source, key.Path); err != nil {
for _, pk := range persisted {
if restoreErr := s.restoreRuleSQLNL(pk, affected[pk]); restoreErr != nil {
logger.Errorf("failed to restore access rule %q/%q in sql after failure: %v", pk.Source, pk.Path, restoreErr)
}
touch(sourcePath, indexPath)
}
}
}
if changed {
s.clearAllCaches()
for sp, paths := range dirty {
for ip := range paths {
s.persistRuleSQLNL(sp, ip)
for k, snap := range affected {
if s.AllRules[k.Source] == nil {
s.AllRules[k.Source] = make(RuleMap)
}
s.AllRules[k.Source][k.Path] = snap
}
return err
}
persisted = append(persisted, key)
}
return nil
}
// RemoveAllRulesForGroup removes a group from all allow and deny lists.
func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
touched := s.removeAllRulesForGroupNL(groupname)
if len(touched) > 0 {
s.clearAllCaches()
for _, key := range touched {
s.persistRuleSQLNL(key.Source, key.Path)
}
}
s.clearAllCaches()
return nil
}
// removeAllRulesForGroupNL removes a group from every allow and deny list in memory and
// returns the rules it touched (rules left empty are deleted). It does not persist; the
// caller must hold s.mux and save the returned rules.
func (s *Storage) removeAllRulesForGroupNL(groupname string) []RuleKey {
// returns the rules it touched (rules left empty are deleted) plus a deep copy of each
// touched rule's previous state for rollback. It does not persist; the caller must hold
// s.mux and save the returned rules.
func (s *Storage) removeAllRulesForGroupNL(groupname string) ([]RuleKey, map[RuleKey]*AccessRule) {
var touched []RuleKey
originals := make(map[RuleKey]*AccessRule)
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
touchedRule := false
if _, exists := rule.Allow.Groups[groupname]; exists {
delete(rule.Allow.Groups, groupname)
touchedRule = true
}
if _, exists := rule.Deny.Groups[groupname]; exists {
delete(rule.Deny.Groups, groupname)
touchedRule = true
_, inAllow := rule.Allow.Groups[groupname]
_, inDeny := rule.Deny.Groups[groupname]
if !inAllow && !inDeny {
continue
}
key := RuleKey{Source: sourcePath, Path: indexPath}
originals[key] = cloneAccessRule(rule)
delete(rule.Allow.Groups, groupname)
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)
}
touchedRule = true
}
if touchedRule {
touched = append(touched, RuleKey{Source: sourcePath, Path: indexPath})
s.deleteStoredRuleNL(sourcePath, indexPath)
}
touched = append(touched, key)
}
}
return touched
return touched, originals
}
func cloneAccessRule(rule *AccessRule) *AccessRule {
if rule == nil {
return nil
}
return &AccessRule{
DenyAll: rule.DenyAll,
Deny: RuleSet{Users: cloneStringSet(rule.Deny.Users), Groups: cloneStringSet(rule.Deny.Groups)},
Allow: RuleSet{Users: cloneStringSet(rule.Allow.Users), Groups: cloneStringSet(rule.Allow.Groups)},
}
}
// GetRulesForUser returns all rules for a specific user for a given sourcePath.
@@ -1320,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
@@ -1356,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 {
@@ -1365,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 {
@@ -1388,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
@@ -1424,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 {
@@ -1433,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 {
@@ -1460,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 {
@@ -1476,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++
}
@@ -1488,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)
}
}
@@ -1505,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 {
@@ -1517,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,79 @@
package access
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// MaxGroupNameLength bounds names for manually created groups. Existing names
// (e.g. LDAP DNs synced from an IdP) may exceed it and are never rejected on read.
const MaxGroupNameLength = 128
// MinGroupNameLength bounds manually created group names. Existing records are
// never rejected so legacy single-character names stay usable.
const MinGroupNameLength = 2
// NormalizeGroupName trims and validates a group name supplied by an admin API.
// It is only used when manually creating or referencing a group; names that
// already exist in the database are always accepted on read, list, edit and
// delete paths so legacy records stay usable.
func NormalizeGroupName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("group name is required")
}
if utf8.RuneCountInString(name) < MinGroupNameLength && !isSingleLetterName(name) {
return "", fmt.Errorf("group name must be at least %d characters", MinGroupNameLength)
}
if utf8.RuneCountInString(name) > MaxGroupNameLength {
return "", fmt.Errorf("group name exceeds %d characters", MaxGroupNameLength)
}
for _, r := range name {
if unicode.IsControl(r) {
return "", fmt.Errorf("group name must not contain control characters")
}
}
return name, nil
}
// isSingleLetterName reports whether name is one non-ASCII letter (e.g. a CJK ideograph),
// which is a meaningful group name on its own.
func isSingleLetterName(name string) bool {
r, size := utf8.DecodeRuneInString(name)
return size == len(name) && r > unicode.MaxASCII && unicode.IsLetter(r)
}
// NormalizeMembers trims, dedupes and drops empty usernames while preserving order.
func NormalizeMembers(usernames []string) []string {
seen := make(map[string]struct{}, len(usernames))
out := make([]string, 0, len(usernames))
for _, name := range usernames {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}
// IsUsableSyncedGroupName reports whether a group name coming from an identity
// provider can be stored. Only the minimum needed to keep rows usable is
// rejected; everything else is kept so IdP naming conventions keep working.
func IsUsableSyncedGroupName(name string) bool {
if strings.TrimSpace(name) == "" {
return false
}
for _, r := range name {
if unicode.IsControl(r) {
return false
}
}
return true
}
@@ -0,0 +1,115 @@
package access_test
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
func TestNormalizeGroupName(t *testing.T) {
for _, tc := range []struct {
name string
in string
want string
wantErr bool
}{
{"trims", " editors ", "editors", false},
{"spaces inside", "cn=admins with spaces,ou=x", "cn=admins with spaces,ou=x", false},
{"empty", "", "", true},
{"blank", " ", "", true},
{"newline", "a\nb", "", true},
{"too short", "a", "", true},
{"single wide char", "界", "界", false},
{"two chars", "ab", "ab", false},
{"single CJK char", "组", "组", false},
{"single non-letter non-ascii", "€", "", true},
{"too long", string(make([]byte, access.MaxGroupNameLength+1)), "", true},
} {
got, err := access.NormalizeGroupName(tc.in)
if tc.wantErr {
if err == nil {
t.Fatalf("%s: expected error, got %q", tc.name, got)
}
continue
}
if err != nil || got != tc.want {
t.Fatalf("%s: got %q, err=%v", tc.name, got, err)
}
}
// Long names of valid characters fail on length, not on content.
if _, err := access.NormalizeGroupName(string(make([]byte, access.MaxGroupNameLength+1))); err == nil {
t.Fatal("expected too-long name to fail")
}
if got := access.NormalizeMembers([]string{" a ", "b", "", "a", " "}); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected members: %v", got)
}
}
func TestRemoveUserFromAllGroups(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob"}); err != nil {
t.Fatal(err)
}
if err := s.SetGroupMembers("staff", []string{"alice"}); err != nil {
t.Fatal(err)
}
if err := s.RemoveUserFromAllGroups("alice"); err != nil {
t.Fatal(err)
}
if groups := s.GetUserGroups("alice"); len(groups) != 0 {
t.Fatalf("alice should be in no groups, got %v", groups)
}
if got := s.GetGroupMembers()["editors"]; len(got) != 1 || got[0] != "bob" {
t.Fatalf("expected [bob], got %v", got)
}
// The emptied group row still exists.
if _, ok := s.GetGroupMembers()["staff"]; !ok {
t.Fatal("empty group should remain")
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["staff"]["alice"]; ok {
t.Fatal("alice should be removed from staff in SQL")
}
// Removing a user who is in no group is a no-op.
if err := s.RemoveUserFromAllGroups("nobody"); err != nil {
t.Fatal(err)
}
}
func TestRenameUserInGroups(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob"}); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInGroups("alice", "alicia"); err != nil {
t.Fatal(err)
}
got := s.GetGroupMembers()["editors"]
if len(got) != 2 || got[0] != "alicia" || got[1] != "bob" {
t.Fatalf("unexpected members: %v", got)
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["editors"]["alicia"]; !ok {
t.Fatal("alicia should be persisted in SQL")
}
if _, ok := sqlGroups["editors"]["alice"]; ok {
t.Fatal("alice should be removed in SQL")
}
// Renaming a non-member or same name is a no-op.
if err := s.RenameUserInGroups("nobody", "other"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInGroups("alicia", "alicia"); err != nil {
t.Fatal(err)
}
}
@@ -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 {
@@ -0,0 +1,129 @@
package access_test
import (
"slices"
"testing"
)
func TestRenameUserInRules(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/allowed"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/denied"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.AllowUser(src, idxPath("/both"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/both"), "alice"); err != nil {
t.Fatal(err)
}
// New name already listed on another path (set semantics: old entry removed, new unchanged).
if err := s.AllowUser(src, idxPath("/existing"), "alicia"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("alice", "alicia"); err != nil {
t.Fatal(err)
}
if rules := s.GetRulesForUser(src, "alice"); len(rules) != 0 {
t.Fatalf("expected no rules for old name alice, got %d", len(rules))
}
aliciaRules := s.GetRulesForUser(src, "alicia")
if len(aliciaRules) != 4 {
t.Fatalf("expected 4 paths for alicia, got %d", len(aliciaRules))
}
for _, path := range []string{"/allowed/", "/denied/", "/both/", "/existing/"} {
if _, ok := aliciaRules[path]; !ok {
t.Fatalf("missing rule at %q: %v", path, aliciaRules)
}
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
for _, rule := range sqlRules[src] {
if _, ok := rule.Allow.Users["alice"]; ok {
t.Fatal("alice should not appear in allow lists in SQL")
}
if _, ok := rule.Deny.Users["alice"]; ok {
t.Fatal("alice should not appear in deny lists in SQL")
}
}
if err := s.RenameUserInRules("nobody", "other"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("alicia", "alicia"); err != nil {
t.Fatal(err)
}
}
func TestRemoveAllRulesForUserOnDelete(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/a"), "bob"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/b"), "bob"); err != nil {
t.Fatal(err)
}
if err := s.RemoveAllRulesForUser("bob"); err != nil {
t.Fatal(err)
}
if rules := s.GetRulesForUser(src, "bob"); len(rules) != 0 {
t.Fatalf("expected no in-memory rules for bob, got %d", len(rules))
}
all, err := s.GetAllRules(src)
if err != nil {
t.Fatal(err)
}
if len(all) != 0 {
t.Fatalf("expected no rules left on source, got %d", len(all))
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
if len(sqlRules[src]) != 0 {
t.Fatalf("expected no SQL rules for source, got %v", sqlRules[src])
}
}
func TestRenameUserInRulesBothAllowAndDenySamePath(t *testing.T) {
setupTestSources()
s, _, _ := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/shared"), "old"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/shared"), "old"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("old", "new"); err != nil {
t.Fatal(err)
}
rules, ok := s.GetFrontendRules(src, idxPath("/shared"))
if !ok {
t.Fatal("expected rule at /shared")
}
if !slices.Contains(rules.Allow.Users, "new") || slices.Contains(rules.Allow.Users, "old") {
t.Fatalf("allow users: %v", rules.Allow.Users)
}
if !slices.Contains(rules.Deny.Users, "new") || slices.Contains(rules.Deny.Users, "old") {
t.Fatalf("deny users: %v", rules.Deny.Users)
}
}
@@ -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,
@@ -21,6 +21,9 @@ type IndexInfo struct {
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
// Root-filesystem-only view (no nested mount aggregation)
UsedDiskRoot uint64 `json:"usedAltRoot"`
DiskTotalRoot uint64 `json:"totalRoot"`
// Scanner information - map of scanner path to scanner stats
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
+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)
@@ -0,0 +1,200 @@
package sql
import (
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
// TestCreatedTime_InsertAndReadBack checks that an inserted Created value is returned unchanged.
func TestCreatedTime_InsertAndReadBack(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_set", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
created := time.Unix(1700000000, 0)
info := &iteminfo.FileInfo{
Path: "/a.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "a.txt",
Size: 12,
Created: &created,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/a.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
got, err := db.GetItem("src1", "/a.txt")
if err != nil {
t.Fatal(err)
}
if got.Created == nil {
t.Fatal("expected non-nil Created")
}
if !got.Created.Equal(created.Truncate(time.Second)) {
t.Errorf("Created = %v, want %v", got.Created, created.Truncate(time.Second))
}
}
// TestCreatedTime_NilStaysNil checks that an item inserted without Created reads back with nil.
func TestCreatedTime_NilStaysNil(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_nil", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
info := &iteminfo.FileInfo{
Path: "/b.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "b.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/b.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
got, err := db.GetItem("src1", "/b.txt")
if err != nil {
t.Fatal(err)
}
if got.Created != nil {
t.Errorf("expected nil Created, got %v", got.Created)
}
}
// TestCreatedTime_UpsertSetsCreated checks that an upsert fills in a previously nil Created.
func TestCreatedTime_UpsertSetsCreated(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_upsert", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
info := &iteminfo.FileInfo{
Path: "/c.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "c.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/c.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
created := time.Unix(1700000000, 0)
info.Created = &created
if updateErr := db.InsertItem("src1", "/c.txt", info); updateErr != nil {
t.Fatal(updateErr)
}
got, err := db.GetItem("src1", "/c.txt")
if err != nil {
t.Fatal(err)
}
if got.Created == nil {
t.Fatal("expected non-nil Created after upsert")
}
if !got.Created.Equal(created.Truncate(time.Second)) {
t.Errorf("Created = %v, want %v", got.Created, created.Truncate(time.Second))
}
}
// TestCreatedTime_MigrationAddsColumn checks that CreateIndexTable adds created_time to an
// old-schema table and can be run repeatedly.
func TestCreatedTime_MigrationAddsColumn(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_migration", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DROP TABLE index_items`); err != nil {
t.Fatal(err)
}
oldSchema := `
CREATE TABLE index_items (
source TEXT NOT NULL,
path TEXT NOT NULL,
parent_path TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
mod_time INTEGER NOT NULL,
type TEXT NOT NULL,
is_dir BOOLEAN NOT NULL,
is_hidden BOOLEAN NOT NULL,
has_preview BOOLEAN NOT NULL,
last_updated INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source, path)
)`
if _, err := db.Exec(oldSchema); err != nil {
t.Fatal(err)
}
if err := db.CreateIndexTable(); err != nil {
t.Fatal(err)
}
if !hasCreatedTimeColumn(t, db) {
t.Fatal("expected created_time column after migration")
}
// Running again must not error (repeatable migration).
if err := db.CreateIndexTable(); err != nil {
t.Fatal(err)
}
if !hasCreatedTimeColumn(t, db) {
t.Fatal("expected created_time column after second CreateIndexTable call")
}
}
// hasCreatedTimeColumn reports whether index_items currently has a created_time column.
func hasCreatedTimeColumn(t *testing.T, db *IndexDB) bool {
t.Helper()
rows, err := db.Query(`PRAGMA table_info(index_items)`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var cid, notNull, pk int
var name, columnType string
var defaultValue interface{}
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &pk); err != nil {
t.Fatal(err)
}
if name == "created_time" {
return true
}
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return false
}
+69 -13
View File
@@ -245,6 +245,7 @@ func (db *IndexDB) CreateIndexTable() error {
parent_path TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
created_time INTEGER NOT NULL DEFAULT 0,
mod_time INTEGER NOT NULL,
type TEXT NOT NULL,
is_dir BOOLEAN NOT NULL,
@@ -259,17 +260,63 @@ func (db *IndexDB) CreateIndexTable() error {
CREATE INDEX IF NOT EXISTS idx_last_updated ON index_items(source, last_updated);
`
_, err := db.Exec(query)
if err != nil {
return err
}
return db.ensureCreatedTimeColumn()
}
// ensureCreatedTimeColumn adds the created_time column to index_items when it is
// missing, so databases created before the column existed are upgraded in place.
func (db *IndexDB) ensureCreatedTimeColumn() error {
rows, err := db.Query(`PRAGMA table_info(index_items)`)
if err != nil {
return err
}
found := false
for rows.Next() {
var cid, notNull, pk int
var name, columnType string
var defaultValue interface{}
scanErr := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &pk)
if scanErr != nil {
rows.Close()
return scanErr
}
if name == "created_time" {
found = true
}
}
if rowsErr := rows.Err(); rowsErr != nil {
rows.Close()
return rowsErr
}
rows.Close()
if found {
return nil
}
_, err = db.Exec(`ALTER TABLE index_items ADD COLUMN created_time INTEGER NOT NULL DEFAULT 0`)
return err
}
// createdUnix converts a creation time to unix seconds for storage, returning 0
// when the time is nil or zero (stored as "unavailable").
func createdUnix(created *time.Time) int64 {
if created == nil || created.IsZero() {
return 0
}
return created.Unix()
}
func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) error {
query := `
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO index_items (source, path, parent_path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source, path) DO UPDATE SET
parent_path = excluded.parent_path,
name = excluded.name,
size = excluded.size,
created_time = excluded.created_time,
mod_time = excluded.mod_time,
type = excluded.type,
is_dir = excluded.is_dir,
@@ -284,6 +331,7 @@ func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) erro
parentPath,
info.Name,
info.Size,
createdUnix(info.Created),
info.ModTime.Unix(),
info.Type,
info.Type == "directory",
@@ -365,12 +413,13 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
}()
stmt, err := tx.Prepare(`
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO index_items (source, path, parent_path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source, path) DO UPDATE SET
parent_path = excluded.parent_path,
name = excluded.name,
size = excluded.size,
created_time = excluded.created_time,
mod_time = excluded.mod_time,
type = excluded.type,
is_dir = excluded.is_dir,
@@ -396,6 +445,7 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
parentPath,
info.Name,
info.Size,
createdUnix(info.Created),
info.ModTime.Unix(),
info.Type,
info.Type == "directory",
@@ -428,7 +478,7 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
// Busy/locked errors are returned so callers can fall back to the filesystem.
func (db *IndexDB) GetItem(source, path string) (*iteminfo.FileInfo, error) {
query := `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items WHERE source = ? AND path = ?
`
row := db.QueryRow(query, source, path)
@@ -456,7 +506,7 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
}
query := fmt.Sprintf(`
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items WHERE source = ? AND path IN (%s)
`, strings.Join(placeholders, ","))
@@ -481,7 +531,7 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
// GetDirectoryChildren retrieves all children of a directory for a specific source.
func (db *IndexDB) GetDirectoryChildren(source, dirPath string) ([]*iteminfo.FileInfo, error) {
query := `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items WHERE source = ? AND parent_path = ?
ORDER BY is_dir DESC, name ASC
`
@@ -927,7 +977,7 @@ func (db *IndexDB) GetFilesForMultipleSizes(source string, sizes []int64, pathPr
if pathPrefix != "" {
nextPrefix := getNextPathPrefix(pathPrefix)
query = fmt.Sprintf(`
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size IN (%s) AND is_dir = 0 AND path >= ? AND path < ?
ORDER BY size, type, name
@@ -935,7 +985,7 @@ func (db *IndexDB) GetFilesForMultipleSizes(source string, sizes []int64, pathPr
args = append(args, pathPrefix, nextPrefix)
} else {
query = fmt.Sprintf(`
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size IN (%s) AND is_dir = 0
ORDER BY size, type, name
@@ -1095,7 +1145,7 @@ func (db *IndexDB) GetFilesBySizeAndType(source string, size int64, fileType str
if pathPrefix != "" {
nextPrefix := getNextPathPrefix(pathPrefix)
query = `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND type = ? AND is_dir = 0 AND path >= ? AND path < ?
ORDER BY name
@@ -1103,7 +1153,7 @@ func (db *IndexDB) GetFilesBySizeAndType(source string, size int64, fileType str
args = []interface{}{source, size, fileType, pathPrefix, nextPrefix}
} else {
query = `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND type = ? AND is_dir = 0
ORDER BY name
@@ -1137,7 +1187,7 @@ func (db *IndexDB) GetFilesBySize(source string, size int64, pathPrefix string)
if pathPrefix != "" {
nextPrefix := getNextPathPrefix(pathPrefix)
query = `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND is_dir = 0 AND path >= ? AND path < ?
ORDER BY name
@@ -1145,7 +1195,7 @@ func (db *IndexDB) GetFilesBySize(source string, size int64, pathPrefix string)
args = []interface{}{source, size, pathPrefix, nextPrefix}
} else {
query = `
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND is_dir = 0
ORDER BY name
@@ -1260,6 +1310,7 @@ func getParentPath(path string) string {
func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileInfo, error) {
var info iteminfo.FileInfo
var createdTime int64
var modTime int64
var isDir bool
@@ -1267,6 +1318,7 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
&info.Path,
&info.Name,
&info.Size,
&createdTime,
&modTime,
&info.Type,
&isDir,
@@ -1276,6 +1328,10 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
if err != nil {
return nil, err
}
if createdTime > 0 {
t := time.Unix(createdTime, 0)
info.Created = &t
}
info.ModTime = time.Unix(modTime, 0)
info.IsDir = isDir
return &info, nil
@@ -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)
}
+13 -12
View File
@@ -8,14 +8,15 @@ import (
// SearchItem represents a single search result row from the database
type SearchItem struct {
Source string
Path string
Name string
Size int64
ModTime int64
Type string
IsDir bool
HasPreview bool
Source string
Path string
Name string
Size int64
CreatedTime int64
ModTime int64
Type string
IsDir bool
HasPreview bool
}
// appendNameGlobSQL adds AND (name GLOB ? OR ...) or AND (name GLOB ? AND ...) when joinAnd is true.
@@ -44,8 +45,8 @@ func appendNameGlobSQL(query string, args []interface{}, patterns []string, join
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd, caseExact bool) (*sql.Rows, error) {
query := `
SELECT path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
SELECT path, name, size, created_time, mod_time, type, is_dir, has_preview
FROM index_items
WHERE source = ?
`
args := []interface{}{source}
@@ -77,8 +78,8 @@ func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[str
}
query := `
SELECT source, path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
SELECT source, path, name, size, created_time, mod_time, type, is_dir, has_preview
FROM index_items
`
args := []interface{}{}
whereClauses := []string{}
+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()
@@ -11,8 +11,14 @@ const (
SidebarLinkSourceAlt SidebarLinkCategory = "source-alt"
SidebarLinkSourceHybrid SidebarLinkCategory = "source-hybrid"
SidebarLinkSourceHybrid2 SidebarLinkCategory = "source-hybrid-2"
SidebarLinkTool SidebarLinkCategory = "tool"
SidebarLinkCustom SidebarLinkCategory = "custom"
// "-root" variants display the root-filesystem-only disk usage (no nested
// mount aggregation) instead of the aggregated multi-mount totals.
SidebarLinkSourceRoot SidebarLinkCategory = "source-root"
SidebarLinkSourceAltRoot SidebarLinkCategory = "source-alt-root"
SidebarLinkSourceHybridRoot SidebarLinkCategory = "source-hybrid-root"
SidebarLinkSourceHybrid2Root SidebarLinkCategory = "source-hybrid-2-root"
SidebarLinkTool SidebarLinkCategory = "tool"
SidebarLinkCustom SidebarLinkCategory = "custom"
)
// NormalizeSidebarLinkCategory returns a known category string, preserving source-* variants.
@@ -23,7 +29,10 @@ func NormalizeSidebarLinkCategory(category string) string {
}
switch SidebarLinkCategory(c) {
case SidebarLinkSource, SidebarLinkSourceMinimal, SidebarLinkSourceAlt,
SidebarLinkSourceHybrid, SidebarLinkSourceHybrid2, SidebarLinkTool, SidebarLinkCustom:
SidebarLinkSourceHybrid, SidebarLinkSourceHybrid2,
SidebarLinkSourceRoot, SidebarLinkSourceAltRoot,
SidebarLinkSourceHybridRoot, SidebarLinkSourceHybrid2Root,
SidebarLinkTool, SidebarLinkCustom:
return c
}
if strings.HasPrefix(c, "source") {
@@ -36,3 +45,14 @@ func NormalizeSidebarLinkCategory(category string) string {
func IsSourceSidebarCategory(category string) bool {
return strings.HasPrefix(NormalizeSidebarLinkCategory(category), "source")
}
// SidebarLinkDiskScopeRoot reports whether the category shows the
// root-filesystem-only disk usage rather than the aggregated nested-mount usage.
func SidebarLinkDiskScopeRoot(category string) bool {
return strings.HasSuffix(NormalizeSidebarLinkCategory(category), "-root")
}
// BaseSidebarLinkCategory strips the root-only "-root" suffix from a category.
func BaseSidebarLinkCategory(category string) string {
return strings.TrimSuffix(NormalizeSidebarLinkCategory(category), "-root")
}
@@ -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)
}
}
}
+88 -22
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+).
@@ -173,17 +233,18 @@ type Preview struct {
// FrontendUser holds fields safe to return from user APIs (embedded on User).
type FrontendUser struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
Username string `json:"username"`
FrontendScopes []FrontendScope `json:"scopes"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
Perm Permissions `json:"perm,omitzero"`
DisableSettings bool `json:"disableSettings"`
Username string `json:"username"`
FrontendScopes []FrontendScope `json:"scopes"`
LockPassword bool `json:"lockPassword"`
RequirePasswordChange bool `json:"requirePasswordChange"`
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
Perm Permissions `json:"perm,omitzero"`
}
// PinnedItems maps source filesystem path -> index directory path -> pinned item names.
@@ -198,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"`
@@ -234,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
@@ -263,6 +327,8 @@ type NonAdminEditable struct {
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
ShowTypeColumn bool `json:"showTypeColumn"` // show the Type column in list and compact views
ShowCreationDateColumn bool `json:"showCreationDateColumn"` // show the Creation date column in list and compact views
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
+2 -1
View File
@@ -18,7 +18,8 @@ var (
ErrAccessDenied = errors.New("access denied")
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrPasswordChangeRequired = errors.New("password change is required for user")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not configured")
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
+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)
}
}
+41
View File
@@ -2,6 +2,7 @@ package state
import (
"fmt"
"slices"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
@@ -102,6 +103,46 @@ func SyncUserGroups(username string, newGroups []string) error {
return accessDb.SyncUserGroups(username, newGroups)
}
// GroupExists reports whether a group name is already registered.
func GroupExists(groupname string) bool {
if accessDb == nil {
return false
}
return slices.Contains(accessDb.GetAllGroups(), groupname)
}
// RemoveUserFromAllGroups removes a username from every group's member list.
func RemoveUserFromAllGroups(username string) error {
if accessDb == nil {
return nil
}
return accessDb.RemoveUserFromAllGroups(username)
}
// RenameUserInGroups replaces one username with another in all group member lists.
func RenameUserInGroups(oldName, newName string) error {
if accessDb == nil {
return nil
}
return accessDb.RenameUserInGroups(oldName, newName)
}
// RenameUserInRules replaces one username with another in all path access allow/deny lists.
func RenameUserInRules(oldName, newName string) error {
if accessDb == nil {
return nil
}
return accessDb.RenameUserInRules(oldName, newName)
}
// RemoveAllRulesForUser removes a username from every path access allow and deny list.
func RemoveAllRulesForUser(username string) error {
if accessDb == nil {
return nil
}
return accessDb.RemoveAllRulesForUser(username)
}
// SetAccessSQLStoreForTest replaces the SQL persister used by access control (tests only).
func SetAccessSQLStoreForTest(store access.SQLPersister) {
if accessDb != nil {
@@ -1,25 +1,24 @@
package state
import (
"crypto/rand"
"fmt"
"math/big"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
const bootstrapAdminPasswordHexBytes = 6 // 12 hex characters
// bootstrapDefaultAdminPassword returns the plaintext password for initial admin creation.
// Uses auth.methods.password.adminPassword (backfilled from auth.adminPassword when unset).
// Blank or "admin" is ignored and a random password is generated and logged once.
// When config adminPassword is blank or "admin", a random speakable password is generated and logged once.
func bootstrapDefaultAdminPassword(username string) (plaintext string, generated bool, err error) {
cfg := settings.PasswordAdminPassword()
if cfg != "" && cfg != "admin" {
return cfg, false, nil
}
plaintext, err = utils.RandomHex(bootstrapAdminPasswordHexBytes)
plaintext, err = generateSpeakableBootstrapPassword()
if err != nil {
return "", false, fmt.Errorf("generate bootstrap admin password: %w", err)
}
@@ -30,3 +29,37 @@ func bootstrapDefaultAdminPassword(username string) (plaintext string, generated
)
return plaintext, true, nil
}
func generateSpeakableBootstrapPassword() (string, error) {
if len(bootstrapWords) == 0 {
return "", fmt.Errorf("bootstrap word list is empty")
}
wordIdx, err := rand.Int(rand.Reader, big.NewInt(int64(len(bootstrapWords))))
if err != nil {
return "", err
}
word := bootstrapWords[wordIdx.Int64()]
primary, err := randomSpeakableCode(bootstrapSpeakablePrimaryCodeLen)
if err != nil {
return "", err
}
secondary, err := randomSpeakableCode(bootstrapSpeakableSecondaryCodeLen)
if err != nil {
return "", err
}
return fmt.Sprintf("%s-%s-%s", word, string(primary), string(secondary)), nil
}
func randomSpeakableCode(n int) ([]byte, error) {
codeRunes := make([]byte, n)
charsetLen := big.NewInt(int64(len(bootstrapSpeakableCharset)))
for i := range codeRunes {
idx, err := rand.Int(rand.Reader, charsetLen)
if err != nil {
return nil, err
}
codeRunes[i] = bootstrapSpeakableCharset[idx.Int64()]
}
return codeRunes, nil
}
@@ -1,12 +1,19 @@
package state
import (
"regexp"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestBootstrapWordsCount(t *testing.T) {
if len(bootstrapWords) != 100 {
t.Fatalf("bootstrapWords len = %d, want 100", len(bootstrapWords))
}
}
func TestBootstrapDefaultAdminPasswordUsesNestedConfigWhenSet(t *testing.T) {
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
origLegacy := settings.Config.Auth.AdminPassword
@@ -51,6 +58,8 @@ func TestBootstrapDefaultAdminPasswordUsesTopLevelWhenNestedUnset(t *testing.T)
}
}
var speakableBootstrapPasswordPattern = regexp.MustCompile(`^[a-z]+-[abcdefghjkmnpqrstuvwxyz23456789]{5}-[abcdefghjkmnpqrstuvwxyz23456789]{2}$`)
func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
origLegacy := settings.Config.Auth.AdminPassword
@@ -72,11 +81,31 @@ func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
if plain == "" || plain == "admin" {
t.Fatalf("cfg=%q: unexpected password %q", cfg, plain)
}
if len(plain) != bootstrapAdminPasswordHexBytes*2 {
t.Fatalf("cfg=%q: password length %d, want %d", cfg, len(plain), bootstrapAdminPasswordHexBytes*2)
if !speakableBootstrapPasswordPattern.MatchString(plain) {
t.Fatalf("cfg=%q: password %q does not match speakable pattern", cfg, plain)
}
if strings.Trim(plain, "0123456789abcdef") != "" {
t.Fatalf("cfg=%q: expected hex password, got %q", cfg, plain)
word := strings.Split(plain, "-")[0]
found := false
for _, w := range bootstrapWords {
if w == word {
found = true
break
}
}
if !found {
t.Fatalf("cfg=%q: word %q not in bootstrap word list", cfg, word)
}
}
}
func TestGenerateSpeakableBootstrapPassword(t *testing.T) {
for i := 0; i < 20; i++ {
plain, err := generateSpeakableBootstrapPassword()
if err != nil {
t.Fatal(err)
}
if !speakableBootstrapPasswordPattern.MatchString(plain) {
t.Fatalf("password %q does not match speakable pattern", plain)
}
}
}
@@ -0,0 +1,23 @@
package state
// bootstrapWords is a fixed list of short, common English nouns for one-time bootstrap passwords.
// Words are chosen to be easy to say, spell, and type (including for non-native English speakers).
var bootstrapWords = []string{
"apple", "ball", "barn", "bean", "bed", "belt", "bike", "bird", "boat", "book",
"boot", "bowl", "box", "bus", "cake", "camp", "cart", "cat", "clock", "cloud",
"coat", "coin", "corn", "cup", "desk", "dog", "door", "duck", "egg", "farm",
"fish", "flag", "food", "foot", "game", "gift", "goat", "gold", "grass", "hand",
"hat", "hill", "home", "horse", "house", "ice", "jar", "key", "kid", "lake",
"lamp", "land", "leaf", "leg", "light", "line", "lion", "list", "mail", "map",
"milk", "moon", "mouse", "nest", "net", "night", "nose", "note", "page", "park",
"path", "pen", "pet", "pie", "pig", "pin", "pipe", "plan", "plant", "pot",
"rain", "red", "ring", "road", "rock", "room", "rope", "rose", "sand", "sea",
"ship", "shoe", "shop", "sky", "snow", "sock", "star", "stone", "sun", "tree",
}
// bootstrapSpeakableCharset excludes ambiguous characters for read-aloud (no 0/o, 1/i/l).
const bootstrapSpeakableCharset = "abcdefghjkmnpqrstuvwxyz23456789"
// bootstrapSpeakablePrimaryCodeLen and bootstrapSpeakableSecondaryCodeLen form the code after the word (e.g. word-xxxxx-xx).
const bootstrapSpeakablePrimaryCodeLen = 5
const bootstrapSpeakableSecondaryCodeLen = 2
+4 -1
View File
@@ -52,10 +52,13 @@ func QuickSetup() error {
logger.Debugf("Creating user as admin: %v", user.Username)
plainPassword, _, err := bootstrapDefaultAdminPassword(user.Username)
plainPassword, generated, err := bootstrapDefaultAdminPassword(user.Username)
if err != nil {
return err
}
if generated {
user.RequirePasswordChange = true
}
hashedPassword, hashErr := utils.HashPwd(plainPassword)
if hashErr != nil {
+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)
+131 -4
View File
@@ -6,18 +6,38 @@ import (
"strings"
"github.com/google/uuid"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/toolaccess"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
// 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 {
@@ -192,6 +212,9 @@ func CreateUser(user *users.User, plaintextPassword string) error {
}
// Hash password if provided
if plaintextPassword != "" {
if err := settings.ValidatePasswordPolicy(plaintextPassword); err != nil {
return err
}
hashedPassword, err := utils.HashPwd(plaintextPassword)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
@@ -199,19 +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.
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)
@@ -296,6 +368,9 @@ func UpdateUser(user *users.User, plaintextPassword string, fields ...string) er
if plaintextPassword == "" {
return fmt.Errorf("password field requires a non-empty plaintext password")
}
if err := settings.ValidatePasswordPolicy(plaintextPassword); err != nil {
return err
}
hashedPassword, hashErr := utils.HashPwd(plaintextPassword)
if hashErr != nil {
return fmt.Errorf("failed to hash password: %w", hashErr)
@@ -408,6 +483,11 @@ func commitUserUpdate(existingUser, storedSnapshot *users.User, sourceDefaults u
if err := sqlDb.UpdateUserUsername(oldUsername, existingUser); err != nil {
return err
}
if accessDb != nil {
if err := renameUserInAccess(oldUsername, existingUser); err != nil {
return err
}
}
if oldUserID != 0 && oldUserID != existingUser.ID {
userRecordCache.Delete(userCacheKeyID(oldUserID))
}
@@ -431,6 +511,31 @@ func commitUserUpdate(existingUser, storedSnapshot *users.User, sourceDefaults u
return nil
}
// renameUserInAccess renames the user in group memberships and path rules after the SQL rename
// has been written. On failure every earlier step is reverted (keeping the other updated fields)
// so the stores never disagree about which username owns the memberships.
func renameUserInAccess(oldUsername string, updated *users.User) error {
revertSQL := func() {
reverted := *updated
reverted.Username = oldUsername
if err := sqlDb.UpdateUser(&reverted); err != nil {
logger.Errorf("failed to revert username %q to %q after rename failure: %v", updated.Username, oldUsername, err)
}
}
if err := accessDb.RenameUserInGroups(oldUsername, updated.Username); err != nil {
revertSQL()
return fmt.Errorf("failed to rename user in groups: %w", err)
}
if err := accessDb.RenameUserInRules(oldUsername, updated.Username); err != nil {
if revErr := accessDb.RenameUserInGroups(updated.Username, oldUsername); revErr != nil {
logger.Errorf("failed to revert group memberships for %q after rename failure: %v", oldUsername, revErr)
}
revertSQL()
return fmt.Errorf("failed to rename user in access rules: %w", err)
}
return nil
}
func removedScopeQuotaIDs(before, after *users.User) []string {
oldByPath := map[string]string{}
for _, bs := range before.BackendScopes {
@@ -577,6 +682,9 @@ func DeleteUser(id uint64) error {
return fmt.Errorf("user not found in state")
}
if err := removeUserAccess(user.Username); err != nil {
return err
}
if err := sqlDb.DeleteUserByID(id); err != nil {
return err
}
@@ -601,6 +709,9 @@ func DeleteUserByUsername(username string) error {
}
uid := user.ID
if err := removeUserAccess(username); err != nil {
return err
}
if err := sqlDb.DeleteUserByUsername(username); err != nil {
return err
}
@@ -610,7 +721,23 @@ func DeleteUserByUsername(username string) error {
if accessDb != nil && uid != 0 {
_ = accessDb.RemoveHashedTokensForUser(uid)
}
return nil
}
// removeUserAccess strips a username from group memberships and path rules before the user row is
// deleted. Each step rolls itself back on failure and any error aborts the delete, so a reused
// username can never inherit leftovers. The failure mode is fail-safe: if the later SQL delete
// fails the user only loses access, and a retry is idempotent.
func removeUserAccess(username string) error {
if accessDb == nil {
return nil
}
if err := accessDb.RemoveUserFromAllGroups(username); err != nil {
return fmt.Errorf("failed to remove user from groups: %w", err)
}
if err := accessDb.RemoveAllRulesForUser(username); err != nil {
return fmt.Errorf("failed to remove user from access rules: %w", err)
}
return nil
}
Loaded 100 of 571 files, more files were not shown because too many files have changed in this diff. Show more