Compare commits

...
162 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
+11 45a14bf2f2 Beta/v2.1.0 (#3046)
Signed-off-by: MichaIng <micha@dietpi.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mattijs Bliek <mattijs@mattijsbliek.com>
Co-authored-by: GammelSami <39372285+GammelSami@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: yzxcj797 <54314860+yzxcj797@users.noreply.github.com>
Co-authored-by: yzxcj797 <yzxcj797@users.noreply.github.com>
Co-authored-by: DireMunchkin <dire.munchkin@proton.me>
Co-authored-by: Chris <39135018+Chri6s@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Aditya Raj Singh <github@adityaraj.com>
Co-authored-by: tbsagdic <86909795+tbsagdic@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ethan Lee Qi Yang <pluscompanyhqDEPT@gmail.com>
Co-authored-by: Aditya Raj Singh <aditya@bncw.in>
Co-authored-by: MichaIng <micha@dietpi.com>
Co-authored-by: FandacqkaCorp <david@davidyloreto.com>
Co-authored-by: FandacqkaCorp <275647464+FandacqkaCorp@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: connorjfarrell <connorjfarrell@users.noreply.github.com>
2026-09-30 13:04:20 -05:00
e13f7075dc Beta/v2.0.9 (#3020)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: FandacqkaCorp <david@davidyloreto.com>
Co-authored-by: FandacqkaCorp <275647464+FandacqkaCorp@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-09-25 19:33:57 -05:00
7a06fb1e5f Beta/v2.0.8 (#2992)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: FandacqkaCorp <david@davidyloreto.com>
Co-authored-by: FandacqkaCorp <275647464+FandacqkaCorp@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 23:25:55 -05:00
Graham Steffaniak 45cd854ed7 fix make dev build error (#2970) 2026-09-18 09:29:19 -05:00
ce066e5d5b Beta/v2.0.7 (#2967)
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: zumbiepig <121742281+zumbiepig@users.noreply.github.com>
2026-09-17 17:31:22 -05:00
zumbiepig 8488c42f5f Add cli init command (#2957) 2026-09-17 13:42:33 -05:00
yoo hyeong chan fa58eac9c0 fix(upload): avoid false stalls during parallel transfers (#2950) 2026-09-12 18:42:26 -05:00
752ff247d3 Beta/v2.0.6 (#2946)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Aditya Raj Singh <aditya@bncw.in>
2026-09-12 18:31:24 -05:00
Vladimir Babin 1d08a4549b Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2928) 2026-09-09 12:41:25 -05:00
Graham SteffaniakandKevin B. b12add4317 Beta/v2.0.5 (#2916)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-09-07 12:55:40 -05:00
Graham Steffaniak 5610cdd2bf fix onlyoffice csp security regression (#2876) (#2900) 2026-09-03 20:31:53 -05:00
Ethan Lee Qi Yang ea85c50946 fix(contributing): typo from improtant to important (#2873) 2026-08-30 08:48:32 -05:00
be47c7a1e7 Beta/v2.0.3 (#2870)
Co-authored-by: Aditya Raj Singh <github@adityaraj.com>
Co-authored-by: tbsagdic <86909795+tbsagdic@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:53:51 -05:00
609e1d6482 Beta/v2.0.2 (#2845)
Co-authored-by: yzxcj797 <54314860+yzxcj797@users.noreply.github.com>
Co-authored-by: yzxcj797 <yzxcj797@users.noreply.github.com>
2026-08-24 19:33:27 -05:00
3c8c02414e Beta/v2.0.1 (#2800)
Co-authored-by: Mattijs Bliek <mattijs@mattijsbliek.com>
Co-authored-by: GammelSami <39372285+GammelSami@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-15 11:06:07 -05:00
Graham Steffaniak 29f9055db9 Update README.md (#2748) 2026-08-07 16:25:25 -05:00
+10 3c02eedca6 Beta/v2.0.0 (#2746)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Gabriel Górski <me@glaeqen.eu>
Co-authored-by: Joel Godfrey <82655889+BaccanoMob@users.noreply.github.com>
Co-authored-by: Shane Chiu <71911511+pwn2ooown@users.noreply.github.com>
Co-authored-by: carloda <35763669+carloda@users.noreply.github.com>
Co-authored-by: Taufik Hidayat <taufikdev88@gmail.com>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: SIDDHU SINGH <siddhu3116@gmail.com>
Co-authored-by: Gaurav Kalal <117557391+Gorupa@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
Co-authored-by: Anatolii Prylutskyi <anpryl@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Graham Steffaniak <graham@debian-lake.ghome.net>
Co-authored-by: Neil <kylelzzz02@icloud.com>
Co-authored-by: Graham <info@quantumx-apps.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:24:38 -05:00
Graham Steffaniak 8c5c92bef0 fixed webdav api token creation bug (#2740) (#2744) 2026-08-07 13:53:36 -05:00
b6c9cc9762 Beta/v1.5.4 (#2705)
Co-authored-by: Vadym <63876858+myUdav4iik@users.noreply.github.com>
Co-authored-by: e-tang <eric@tyo.com.au>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 12:39:44 -05:00
42c71c75cb Beta/v1.5.3 (#2700)
Co-authored-by: Vadym <63876858+myUdav4iik@users.noreply.github.com>
Co-authored-by: e-tang <eric@tyo.com.au>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 10:29:56 -05:00
Graham SteffaniakandKevin B. 9503b532e1 Beta/v1.5.2 (#2645)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-07-17 20:09:28 -05:00
Graham Steffaniak a825ddd301 Update README.md (#2646) 2026-07-17 19:08:18 -05:00
da085bca69 Beta/v1.5.1 (#2630)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
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: Graham <info@quantumx-apps.com>
2026-07-13 11:51:51 -05:00
Stephan Paternotte 64250ffff0 Update nl.json (#2603) 2026-07-05 08:18:28 -05:00
Graham Steffaniak 0bc8889c00 Update README.md (#2570) 2026-06-23 10:18:23 -05:00
6eaead073a Beta/v1.5.0 (#2546)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-06-19 11:17:57 -05:00
Graham Steffaniak 826b060bb1 Beta/v1.4.4 (#2536) 2026-06-11 10:52:01 -05:00
21dfc6169e Beta/v1.4.3 (#2530)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Graham Steffaniak <graham@debian-lake.ghome.net>
Co-authored-by: Neil <kylelzzz02@icloud.com>
Co-authored-by: Graham <info@quantumx-apps.com>
Co-authored-by: Anatolii Prylutskyi <anpryl@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:36:10 -05:00
4708faec5f fix(preview): keep opened file selected after closing its preview (#2515)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-06-09 14:48:04 -05:00
fa5abc8c67 Beta/v1.4.2 (#2485)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-22 11:14:27 -05:00
687dc01293 Update en.json (#2456)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 14:43:31 -05:00
bfde65a41e Update nl.json (#2448)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 14:43:18 -05:00
1802e12811 updated to fix critical bug (#2424) (#2427)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
2026-05-14 10:47:26 -05:00
0f215333ee Beta/v1.4.0 (#2421)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
2026-05-13 14:56:35 -05:00
Graham Steffaniak 6d81d1973c Beta/v1.3.10 (#2395) 2026-05-08 14:06:45 -05:00
Graham Steffaniak 869b640a99 Update auth.go (#2381) 2026-05-04 13:32:54 -05:00
Stephan Paternotte 3b8d179774 Update nl.json (#2383) 2026-05-04 12:26:28 -05:00
Graham Steffaniak 569314cdf4 Beta/v1.3.9 (#2377) 2026-05-01 16:11:48 -05:00
Graham Steffaniak 668efec4d7 added new screenshots (#2371) 2026-04-30 15:50:36 -05:00
Graham Steffaniak a5c81c8e2e Beta/v1.3.8 (#2363) 2026-04-30 11:55:14 -05:00
Graham Steffaniak 88aa337696 Beta/v1.3.7 (#2350) 2026-04-27 16:32:13 -05:00
9b48ce414e Beta/v1.3.6 (#2324)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-20 17:00:10 -05:00
0723edf660 Beta/v1.3.5 (#2316)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-18 18:24:10 -05:00
Gaurav Kalal d2165ec0c3 Fix: Prevent portrait mobile overflow for large placeholder loader (#2272) 2026-04-13 09:59:10 -05:00
78c554719f Beta/v1.3.4 (#2273)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: SIDDHU SINGH <siddhu3116@gmail.com>
Co-authored-by: Taufik Hidayat <taufikdev88@gmail.com>
2026-04-11 19:48:26 -05:00
Taufik Hidayat aa8f9e2923 Add resolve nested groupAdmin to oidc auth method (#2260) 2026-04-10 15:39:49 -05:00
1211 changed files with 190463 additions and 54245 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
{
"name": "filebrowser",
"image": "mcr.microsoft.com/devcontainers/go:1.25-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "22"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"containerEnv": {
"CGO_ENABLED": "1",
"FILEBROWSER_DEVMODE": "true"
},
"forwardPorts": [80],
"portsAttributes": {
"80": {
"label": "filebrowser app",
"onAutoForward": "notify"
}
},
"postCreateCommand": "make setup",
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
]
}
}
}
+6 -1
View File
@@ -1,10 +1,15 @@
backend/**.db
backend/*.sqlite
backend/*.sqlite.*
**/*.sqlite
**/*.sqlite.*
backend/filebrowser.sqlite*
**.crt
**.key
backend/tmp
backend/dist
backend/backend*
backend/http/dist/**
backend/internal/web/dist/**
backend/vendor/**
frontend/dist/**
+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
[*.go]
indent_size = 4
[{Makefile,makefile}]
indent_style = tab
+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"
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
create-dev-branch:
name: Create Dev Branch
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Check out repository
+6 -1
View File
@@ -5,10 +5,13 @@ on:
branches:
- "main"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -17,6 +20,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
+26 -8
View File
@@ -11,7 +11,7 @@ on:
jobs:
prime_docker_cache:
name: Prime docker cache
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -28,11 +28,13 @@ jobs:
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
build_frontend:
name: Build frontend
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- name: Build frontend
working-directory: frontend
run: npm i && npm run build
@@ -40,10 +42,10 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: dist
path: backend/http/dist
path: backend/internal/web/dist
build_backend:
name: Build backend
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -61,7 +63,7 @@ jobs:
test_playwright:
needs: [build_backend, build_frontend, prime_docker_cache]
name: Test Playwright - ${{ matrix.name }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
strategy:
fail-fast: false
matrix:
@@ -86,6 +88,8 @@ jobs:
dockerfile: ./_docker/Dockerfile.playwright-previews
- name: jwt
dockerfile: ./_docker/Dockerfile.playwright-jwt
- name: performance
dockerfile: ./_docker/Dockerfile.playwright-performance
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -103,8 +107,8 @@ jobs:
run: chmod +x backend/filebrowser
- name: Copy frontend build to backend
run: |
mkdir -p backend/http/embed
cp -r frontend/dist/* backend/http/embed
mkdir -p backend/internal/web/embed
cp -r frontend/dist/* backend/internal/web/embed
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
@@ -122,9 +126,23 @@ jobs:
type=gha,scope=main-playwright-base
cache-to: |
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
- name: Extract listing performance results
if: matrix.name == 'performance' && success()
run: |
mkdir -p perf-artifact
cid=$(docker create "${{ steps.build.outputs.imageid }}")
docker cp "$cid:/app/frontend/test-results/listing-performance-perf/results.json" perf-artifact/results.json
docker rm "$cid"
- name: Upload listing performance results
if: matrix.name == 'performance' && success()
uses: actions/upload-artifact@v4
with:
name: listing-performance-results
path: perf-artifact/results.json
retention-days: 7
push_pr_to_registry:
name: Build docker image
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -1,88 +0,0 @@
name: Promote Beta to Stable
on:
workflow_dispatch:
inputs:
version:
description: "Version to promote (format: 0.0.0)"
required: true
type: string
jobs:
promote-beta-to-stable:
name: Promote Beta to Stable
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }} # Uses Personal Access Token for branch operations
- name: Validate version format
id: validate_version
run: |
VERSION="${{ github.event.inputs.version }}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ ERROR: Version must be in '0.0.0' format."
exit 1
fi
echo "✅ Version format validated: $VERSION"
- name: Check if beta branch exists
id: check_beta_branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
if ! git ls-remote --exit-code origin "$BETA_BRANCH"; then
echo "❌ ERROR: Beta branch '$BETA_BRANCH' does not exist!"
exit 1
fi
echo "✅ Beta branch '$BETA_BRANCH' exists."
- name: Check if stable branch already exists
id: check_stable_branch
run: |
VERSION="${{ github.event.inputs.version }}"
STABLE_BRANCH="stable/v$VERSION"
if git ls-remote --exit-code origin "$STABLE_BRANCH"; then
echo "❌ ERROR: Stable branch '$STABLE_BRANCH' already exists!"
exit 1
fi
echo "✅ Stable branch '$STABLE_BRANCH' does not exist."
- name: Create and push new stable branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
STABLE_BRANCH="stable/v$VERSION"
# Fetch latest changes
git fetch origin "$BETA_BRANCH"
# Create new stable branch from beta branch
git checkout -b "$STABLE_BRANCH" origin/"$BETA_BRANCH"
# Push new stable branch to remote
git push origin "$STABLE_BRANCH"
echo "✅ Successfully created stable branch '$STABLE_BRANCH' from '$BETA_BRANCH'."
- name: Merge beta into stable
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
STABLE_BRANCH="stable/v$VERSION"
# Checkout stable branch
git checkout "$STABLE_BRANCH"
# Merge beta into stable
git merge --no-ff "origin/$BETA_BRANCH" -m "Merge $BETA_BRANCH into $STABLE_BRANCH"
# Push merge changes
git push origin "$STABLE_BRANCH"
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
promote-dev-to-beta:
name: Promote Dev to Beta
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Check out repository
+35 -13
View File
@@ -7,7 +7,8 @@ on:
jobs:
test-backend:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -16,19 +17,20 @@ jobs:
- working-directory: backend
run: go test -race -v ./... -timeout 30s
lint-backend:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- uses: golangci/golangci-lint-action@v7
with:
version: 'v2.1.6'
working-directory: backend
install-mode: goinstall
- name: Lint backend
run: |
GOLANGCI_LINT="$(cd backend/build && go tool -n golangci-lint)"
cd backend && "$GOLANGCI_LINT" run --path-prefix=backend
format-backend:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -37,32 +39,52 @@ jobs:
- working-directory: backend
run: go fmt ./...
lint-frontend:
runs-on: ubuntu-latest
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 lint
test-frontend:
runs-on: ubuntu-latest
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 test
check-translations:
runs-on: ubuntu-latest
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 i18n:check
check-docs:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- run: make generate-docs
- run: git diff --exit-code
- 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
-66
View File
@@ -1,66 +0,0 @@
name: beta release
on:
push:
branches:
- "beta/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: true
make_latest: false
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
-66
View File
@@ -1,66 +0,0 @@
name: stable release
on:
push:
branches:
- "stable/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false
make_latest: true
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
+37 -19
View File
@@ -11,12 +11,14 @@ permissions:
jobs:
update_tag:
name: Update Release tag
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- name: Install dependencies and build frontend
run: npm i && npm run build
working-directory: frontend
@@ -59,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
@@ -81,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",
@@ -144,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
@@ -167,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 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
publish_playwright_base:
name: Publish playwright-base to GHCR
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
permissions:
contents: read
packages: write
+21 -3
View File
@@ -1,7 +1,12 @@
*.db
*.db.old
# Playwright settings migration fixture (BoltDB → SQLite tests + Docker settings image)
!_docker/src/settings/backend/database.db.old
*.sqlite**
*.bak
*.log
*.mjs
!frontend/scripts/serve-perf-dashboard.mjs
_old
rice-box.go
.idea/
@@ -15,20 +20,33 @@ frontend/tsconfig.tsbuildinfo
/backend/dist
/frontend/dist
/frontend/pkg
/frontend/.cache
/frontend/test-results
# Playwright's default HTML report output dir (written when a config enables the
# "html" reporter). Generated output, never committed.
/frontend/playwright-report
/frontend/playwright-files
/frontend/screenshots
/frontend/generated
frontend/.venv-icons
frontend/.tmp-icons-*
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/frontend/tests/playwright/performance/results.json
# Generated perf report artifacts. NOTE: perf-baseline.json is intentionally NOT
# ignored — it is the committed chromium baseline that CI gates against.
/frontend/tests/playwright/performance/report.json
/frontend/tests/playwright/performance/report.md
/frontend/sharePrepStorage.json
/backend/vendor
/backend/*.cov
/backend/*test_config.yaml
/backend/generated.yaml
/backend/srv
/backend/http/dist
/backend/http/embed
/backend/internal/web/dist
/backend/internal/web/embed
/backend/debug
/backend/http
.DS_Store
node_modules
@@ -51,7 +69,7 @@ yarn-error.log*
*.sln
*.sw*
bin/
build/
/build/
*__debug*
backend/custom.css
+681 -5
View File
@@ -2,6 +2,682 @@
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**:
- [Moderate] Conflicting upload responses no longer synchronously drain the request body before returning HTTP 409; the server now closes the body and marks the connection for closure, preventing denial-of-service from clients that never send EOF (CWE-400).
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
**New Features**:
- Added Storage Quotas
- configurable by folder, source, user, and share.
- Administrators can create, edit, view, and delete folder quotas through the interface.
- Quota information now appears in source views, share details, sidebar progress bars, and folder prompts.
- Enhancements to the editor (#2714):
- Added a floating button to open a resizable split view with live editing for markdown files, the scrolling is synced in both sides and scrolling in single-view modes (the viewer and editor in non-split) now also tries to sync with each other so you don't lose context easily.
- Added a toolbar: This toolbar is present on all the files.
- JSON files have a button to toggle between formatted and minified versions (#2854) (#2566).
- Markdown files have a richer toolbar with lots of quick actions, for example you can now browse, navigate and insert images more easily from your current source.
- Added a prompt to configure some editor settings in UI, that prompt is accessible via the toolbar in the three-dots menu, there you can configure things like:
- Word wrap, keybinds, autocompletion, scrollbar, etc.
- The markdown viewer now supports rendering LaTeX Math and Chemistry formulas.
- Improved and fixed some styles that weren't following the app light/dark theme (#2949).
- Proxy auth group support: `groupsClaim`, `adminGroup`, and `userGroups` now control role-based access when the proxy sends a group/role header (#2755). Admin is determined by `adminGroup` only.
- Moved `auth.adminUsername` and `auth.adminPassword` to `auth.methods.password` (legacy top-level keys are migrated automatically on load).
- Support for `.elrc` (word-by-word lyrics), `.vtt`, `.srt` sidecar files for lyrics in audio files in the media player. (#2838)
- Added setting to configure the placement of the prompts close button in profile settings (#2853) (#2595).
- Added "New file templates". You can now add and configure pre-defined filenames + extension for the creation of new files in the context menu on profile settings! (#2881) (#1239).
- sidebar links can be bulk edited as yaml (#1963) (settings > user management)
- admins can customize more defaults:
- share creation defaults and enforcements (#1692) (#2434) (#2279) (#2812) (settings > share management)
- sidebar link defaults and enforcements (#2561) (settings > user management)
- tool defaults and enforcement (settings > user management)
- Add cli init CLI command (#2957)
- Sort header in file listings inside prompts like move/copy/quick jump, is now sticky. Also added a button to show a search input and filter the items of the current listing (#3028).
- Quick jump now scrolls and highlights the current item you are previewing (#3028).
- When hovering `date` and `name` in the listings, will show up the formatted/raw date (the inverse of what you have configured to display) and the full filename (#3028) (#2623).
- User groups can now be managed from the WebUI under `User Management` -> `Groups` and when editing a user (#2983) (#3040).
**Notes**:
- An Admin can remove a `defautlEnabled` source for a user and it will remove removed until an admin adds it back.
- Enhancements to cross-source copy/move permissions and behavior.
- Improved resume upload behavior.
- Play/pause on videos in mobile now is toggled by the button in the middle rather than the whole container (#2828).
- improved video thumbnail generation speed and efficiency.
- Added icons based on extension to the upload prompt and new file/folder/rename prompts (#2881).
- Profile settings show minimal options by default. Full advanced profile settings can be shown by default by enabling `account.showAdvancedProfile`.
- sidebar links/navigation is button group toggle for clearer visibility
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
- Added risc-v to official releases
- Polished a bit some styles across various places of the UI (#3019) (#3028).
- Moved `Access Management` under `User Management` in the UI (#3040).
- improved developer experience with native vue webhook reloads.
- improved ffmpeg handling and concurrency via library upgrades
**Bug Fixes**:
- Undo in a fresh opened file on the editor was setting the file empty (#2714)
- Added some missing styles in the markdown viewer (#2714)
- Recaptcha not working (#1925) (#2861)
- Fix disk-usage overstatement on virtiofs bind mounts (#2894)
- Support non-ASCII share passwords (#2933)
- Fall back to buffered copies when FUSE rejects fast paths (#2938)
- Preserve deleted sidebar links across restarts (#2935)
- scope padding to listing view (#2934)
- Hide the Replace option on upload/create conflict prompts when the user lacks modify permission (or when a public share disallows replacements), so create-only users are not offered an action that the server rejects (#2837)
- Preserve Ctrl-click file selection when keyboard state is stale (#2958) (#2923)
- Avoid false stalls during parallel transfers (#2950) (#2948) thanks @gudcks0305
- Cap source usage-bar percentage at 100% when indexed size exceeds partition total (#2761) (#2238)
- 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**:
- Fresh install with default `admin`/`admin` startup generates a random initial password and logs it once (#2977). Updated documentation to follow.
- OIDC login binds OAuth `state` to an HttpOnly cookie (crypto-random) and rejects tampered callbacks; post-login redirects must be same-app relative paths.
- Login, logout, and session-expiry redirects reject open-redirect targets (`//`, `/\\`, off-site URLs); signup sends credentials in a JSON body instead of query parameters.
**Notes**:
- Webdav always shows hidden files, ignores user preference. (#3004)
- SQLite shared cache (`cache=shared`) removed from the application database connection: its table-lock conflicts bypass `busy_timeout` and fail immediately. WAL mode already allows concurrent readers.
**Bugfixes**:
- Fixed intermittent blank page / blocked inline SPA script after CSP hardening (#2995)
- ffmpeg unresponsive lock issue (#2996)
- fixed token migration lockout regression from v2.0.8-beta (#2999)
- Public shares with OnlyOffice disabled (`enableOnlyOffice=false`) now reject `/office/config` and `/office/callback` requests server-side instead of only hiding the editor in the UI
- Session/API token registrations now record the JWT expiry; expired mappings are pruned at startup, and tokens expired beyond the same 2-minute grace window used for session rotation no longer resolve a user identity on public share routes
- Startup now fails when the configured/env auth signing key differs from the key persisted in the application database, instead of warning and minting tokens with divergent keys
- OnlyOffice document downloads now re-validate redirect targets against the configured document-server host, closing an SSRF gap where a redirect could send the fetch to an internal address
- OnlyOffice "closed with changes" callbacks now keep the document key until the save succeeds, so a failed save can be retried by the document server instead of being rejected as an unknown session
- External JWT (JwtAuth) requests now reuse an existing valid session cookie instead of minting and registering a new session token on every request
- SQLite index and application databases configure `busy_timeout` on every pooled connection, and busy/locked detection now uses driver result codes; index batch writes and maintenance no longer report success when the DB stays busy, and index cache reads return busy errors instead of empty results so listing can fall back to the filesystem.
## v2.0.8
**Security**:
- [Critical] A forged JWT could authenticate as any known `belongsTo`. Auth signing keys are now persisted in the application database and JWT validation fails closed when no key is configured. (GHSA-8f9r-wg7w-pfw) (#2987) Thanks @d3do-23 and @whoamis3c.
- [Medium] Public upload shares with replacements disabled (`allowReplacements=false`) now reject overwrites when clients send `override=true`. (GHSA-3846-gh75-gp3m) Thanks @d3do-23
- [Medium] OnlyOffice document-server callbacks now verify the shared `integrations.office.secret` JWT (HS256), reject unsigned callback bodies when a secret is configured, and require the callback document key to match the cached editor session for the target path (fail closed on cache miss or path lookup failure).
**New Features**:
- add "Upload only what's missing" to the upload conflict prompt (#2985) (#2553)
**Notes**:
- Improved UI responsiveness for larger directories and firefox, marginal improvement to memory (#1773) (#2879)
- `/api/resources/download` and `/public/api/resources/download` now return HTTP 404 when the requested file or directory does not exist, instead of 500 (#2981)
- `GET /api/resources` returns 400 when the `path` query parameter is missing or empty instead of 500 (#2801)
**Bugfixes**:
- Public share folder and multi-file ZIP downloads were empty for anonymous visitors when the source used deny-by-default or path access rules (#2631) (#2365)
- 401 on pdf download button action (#2978)
- OnlyOffice saves could no-op with HTTP 200 when JWT was enabled: callback POST bodies wrapped in `token` were ignored and JWT parsing omitted fields such as `url` required to download the updated document from the Document Server.
## v2.0.7
**New Features**:
- Add cli init CLI command (#2957)
**Notes**:
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
- Added risc-v to official releases
**Bugfixes**:
- Hide the Replace option on upload/create conflict prompts when the user lacks modify permission (or when a public share disallows replacements), so create-only users are not offered an action that the server rejects (#2837)
- Preserve Ctrl-click file selection when keyboard state is stale (#2958) (#2923)
- Avoid false stalls during parallel transfers (#2950) (#2948) thanks @gudcks0305
- Cap source usage-bar percentage at 100% when indexed size exceeds partition total (#2761) (#2238)
- On Linux, source partition totals sum distinct filesystems mounted under the source root (nested mounts) so usage bars match indexed content (#2761)
## v2.0.6
**Security**:
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
**Notes**:
- renaming sources updates sidebar links (#2878)
- disabling/deleting a user source removes that source from sidebar links (#2942)
**Bugfixes**:
- Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2894)
- Support non-ASCII share passwords (#2933)
- Fall back to buffered copies when FUSE rejects fast paths (#2938) (#2924)
- Preserve deleted sidebar links across restarts (#2935)
## v2.0.5
**New Features**:
- Users can set default view mode and thumbnail size from Profile settings (Listing options). Admins can set the same defaults for existing users from the user edit prompt. Values persist to the account and apply on new browsers or devices; they do not override view preferences already saved locally ([#2884](https://github.com/gtsteffaniak/filebrowser/issues/2884)).
- Admins can edit all profile preference defaults for existing users from the user management panel (listing, preview, sidebar, theme, and related options), matching what was previously available only for new users and global user defaults.
**Security**:
- [Moderate] public metadata api returns file content to anonymous share visitors, ignoring the share's download limit and file-viewer setting (GHSA-55mw-cwg7-m8f5) -- thanks @kta1kri
**Bugfixes**:
- Fixed infinite loading spinner on `/` and `/login` after CSP security hardening ([#2886](https://github.com/gtsteffaniak/filebrowser/issues/2886), [#2890](https://github.com/gtsteffaniak/filebrowser/issues/2890))
- fixed darkmode styling bug where background wouldn't respect configured settings when not logged in
- fixed styling bug where padding was missing from right side of listing view.
- tilde paths for sources were not properly expanded. (eg "~" for home)
- fixed tooltip not showing up for some circumstances on mobile, improved consistency.
- a few styling fixes for inconsistencies (#2908)
- Disk-usage widget no longer reports PB/TB-scale garbage on Docker Desktop for Mac (virtiofs) bind mounts; space is computed from the filesystem fragment size (`f_frsize`) instead of the optimal-transfer block size (`f_bsize`) on Linux ([#2894](https://github.com/gtsteffaniak/filebrowser/issues/2894)).
## v2.0.4
**Bugfixes**:
- OnlyOffice editor failed to load after the CSP security fix; allow configured `integrations.office.url` and `integrations.office.internalUrl` origins in `script-src` ([#2874](https://github.com/gtsteffaniak/filebrowser/issues/2874)).
## v2.0.3
**Security**:
- [High] Stored XSS via HTML preview: strip `<script>` from sanitized srcdoc, remove `allow-same-origin` from the preview iframe sandbox, set HttpOnly on the session cookie, and add a nonce-based `script-src` CSP on the SPA shell (GHSA-vvm6-jwrf-hgmg) -- thanks @qrn12580
- [Moderate] Non-admin users could PATCH privileged fields on their own account (scopes, permissions, etc.), allowing privilege escalation; restores v1 non-admin field guard on PATCH /api/users (GHSA-p5cc-4p84-c4m2) -- thanks @Chri6s
**New Features**:
- Added option to globally disable the "Install App" message via `frontend.disablePWAInstall`
- PWA improvements for installed mobile apps: dedicated maskable icons (192/512), manifest and splash colors that follow the instance default theme, runtime `theme-color` sync on dark-mode toggle, and edge-to-edge safe-area layout for notched devices (#2625) (#2869) -- thanks @APatenaude
**Bugfixes**:
- Fixed Fuji `.raf` thumbnail preview by extracting the camera-embedded JPEG from the RAF header (regression for files where TIFF-based raw extraction does not apply).
- Image previews for unsupported decodable formats now return HTTP 415 immediately instead of attempting JPEG decode and returning HTTP 500.
- OIDC: preserve the verified ID-token user identifier when falling back to the UserInfo endpoint for missing groups, so login no longer fails with HTTP 500 when UserInfo returns groups but omits the configured identifier.
- LDAP: restore `memberOf` as the default `groupsClaim` when unset, fixing group-based admin and login authorization for existing LDAP configs after the v2.0.2 default changed to `groups`.
- "Install app" message reappears after being cleared on device.
- Restored upload chunk size `0` to disable chunking as documented ([#2202](https://github.com/gtsteffaniak/filebrowser/issues/2202)).
- Fixed iOS 26 / WebKit multi-chunk upload stall by isolating chunk connections and returning partial chunk JSON responses ([#2734](https://github.com/gtsteffaniak/filebrowser/issues/2734)).
- Sidebar folder links with custom names, icons, or styles no longer disappear after restart; multiple shortcuts to different folders on the same source are preserved ([#2809](https://github.com/gtsteffaniak/filebrowser/issues/2809)).
- Adding a source to a user via scopes now auto-adds a matching sidebar link; removing a source keeps the link (shown disabled) so users can delete it manually.
- A password reset via CLI returns user to a password method user.
- Authentication login methods are consistently enforced without modifying existing account settings.
## v2.0.2
**Bugfixes**:
- OIDC: `groupsClaim` value is always included in requested scopes and falls back to the UserInfo endpoint when the ID token omits the groups claim.
- Windows: fix backslash duplication in navigation URLs, folder sizes showing 4 KB, and download/preview failures (#2815, #2816)
- Wrong extension in the 'new database was created popup (#2817) (#2824)
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
- OnlyOffice is inaccessible on password-protected shares (#2811)
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
- Queue buttons not clickable in the desktop panel in audio files.
## v2.0.1
**Security**:
- [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds
- [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo
- [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu)
- [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec
- [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209
**Notes**:
- GroupMap `SyncUserGroups` fix so JWT/OIDC/LDAP group memberships survive restart (#2742).
- Session renew is handled by client keep-alive. removed per-request `X-Renew-Token` header handling.
- Improvements to document thumbnail generation performance.
- behavior changes for typing to select files in listing view.
- next/previous buttons don't hide automatically on photos (#2767)
- added more actions available in advanced search (#2776)
**Bugfixes**:
- GroupMap mutations (`SyncUserGroups`, add/remove group members) are write-through to SQL so JWT/OIDC/LDAP group memberships survive restart (#2742).
- `defaultEnabled` behavior change/fix so defaultEnabled means its always added to users on startup and login. (updated docs too)
- Long uploads/downloads no longer lose the session mid-transfer: proactive session JWT keep-alive renews before expiry (#2638).
- Disable auto logout/session expiry if there are active file transfers going (#2638)
- upload options get cut off on mobile (#2685)
- Uploading image: image gets cut-off (#2765)
- Process aborts (not just 500) during PDF thumbnail generation (#2763)
- PDF preview generation prevents file uploads from completing (#2752)
- Brings back double-tap to zoom images that was mistakenly removed.
- multiple embedded subtitles with same language bug (#2756)
- gallery view download button missing (#2767)
- fixed HEIC rotation regression from v1.5.x
- fixed raw image preview regression from v1.5.x
- html viewer takes full height
- Members without download permission receive 403 when opening text-based files despite OnlyOffice preview being enabled (#2777)
## v2.0.0
This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary, and the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert legacy config.
**Breaking Changes**:
- Removed: `GET /api/raw` and `GET /public/api/raw` download routes — use `/api/resources/download` instead.
- Removed: `/share/…` URL redirect to `/public/share/…` — use `/public/share/…` directly.
- Removed: singular `source` search api param (use `sources`), bare `scope` paths without `sourceName:` prefix, and `glob` / `useGlob` aliases (use `useWildcard`).
- Removed `config.conditionals`, source-level `indexingIntervalMinutes` (indexing always uses adaptive scheduling), and deprecated rule fields `fileNames` / `folderNames` / top-level `hidden` — use `config.rules` with `fileName`, `folderName`, and `ignoreHidden` on rules. See [Exclusion rules](https://filebrowserquantum.com/en/docs/user-guides/general-configuration/exclusion-rules/).
- Removed: deprecated `userDefaults` config formats (nested and flat) — use the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert before upgrading.
- Changed: `PUT /api/users` moved to the more appropriate `PATCH` method and requires specifying `which` in the body. Blank or `all` values are rejected.
- Changed: http related config options in `server` config key moved to `http` config key. See [HTTP settings](https://filebrowserquantum.com/en/docs/configuration/http/).
- Changed (reverse proxy): `http.trustedHeaders` (v1.5.x list) removed — use `http.trustProxyHeaders: true` when behind nginx, Traefik, or Caddy. When enabled, FileBrowser honors `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-For`, and `X-Real-IP` for client IP, cookies, OIDC callbacks, WebAuthn, share URLs, rate limiting, and activity logs. Default is `false` (direct connection values). The [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) converts v1 `trustedHeaders` lists to `trustProxyHeaders: true`. See [Reverse proxy](https://filebrowserquantum.com/en/docs/getting-started/reverse-proxy/) and [HTTP trustProxyHeaders](https://filebrowserquantum.com/en/docs/configuration/http/#trustproxyheaders).
- Changed: `FILEBROWSER_DATABASE` environment variable — use `FILEBROWSER_DATABASE_PATH` instead. See [Environment variables](https://filebrowserquantum.com/en/docs/reference/environment-variables/) and [Server settings](https://filebrowserquantum.com/en/docs/configuration/server/).
- Changed: Moved stream api to `/api/media/stream`. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
- Changed: CLI user management — canonical commands are `user set <username> --password [value]` and `user promote <username>`; `set -u username,password` is deprecated. See [CLI reference](https://filebrowserquantum.com/en/docs/reference/cli/).
**New Features**:
- View grant mechanism to distinguish between UI viewing and download. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
- granular per-source file permissions (view, download, modify, create, delete) with automatic migration from global permissions
- per-source defaults configurable in `settings > access management`
- `view` permission is automatically set to true unless explicitly set to false. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
- can be enforced for all users. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
- New activity logs for user activity.
- charts and historical data
- export to csv reports
- Media player improvements:
- Refreshed playback queue UI: Supports thumbnails, stored into session storage, and has a "clear queue" button (#2575) (#2600).
- Loop now has 3 states (off/single/all) and neither of them will clear the existing queue (#2600).
- New "Audio visualizer" for audio files (desktop-only), you can configure some basic things to your taste (#2575) (#2620).
- The current state of the audio panel now is stored into local storage.
- More gestures: Swipe up to enter/exit fullscreen, long-press to change playback speed, single tap to pause (#2575).
- Videos now will resume fullscreen and PiP when navigating (queue auto-navigation, swipes gestures or next/previous) (#2649).
- Added `F4` shortcut to refresh the current directory and metadata (#2600).
- opt-in feature to send deployment analytics to filebrowser quantum developer servers
- anonymized with a viewer so users can see what info would be sent.
- if opt-in, every month a snapshot of your deployment config would be sent to developer servers
- this will help me know what features are being used and what versions everyone is on over time. I will also provide a public dashboard with this information in the future.
- WebDAV now supports set modification time via the `X-OC-Mtime` header for clients that support it (#2626). See [WebDAV docs](https://filebrowserquantum.com/en/docs/features/webdav/).
- Copy operations now preserve their original modification times (#2642) (#2647):
- WebUI preserves both, files and directories.
- WebDAV `COPY` preserves modification times only for files, is limitation we have with webdav.
- User default enhancements
- Config `userDefaults` seeds SQLite on first run; only fields explicitly set in config stay locked in **Settings → User defaults** (other defaults remain editable)
- Added administrator controls for universal user defaults and enforced preferences in `settings > user management > user defaults`.
- Added configurable default file permissions per source in `settings > access management`.
- Added a User Defaults editor for account, permission, and profile preferences in the edit/create user prompt. See [User management](https://filebrowserquantum.com/en/docs/configuration/users/).
- Database env var rename: `FILEBROWSER_DATABASE` is removed (startup fails if set). Use `FILEBROWSER_DATABASE_PATH` (default `filebrowser.sqlite`) or `server.database.path` in config. See [Environment variables](https://filebrowserquantum.com/en/docs/reference/environment-variables/) and [Server settings](https://filebrowserquantum.com/en/docs/configuration/server/).
- CLI: `user set` with `--password` (inline value, interactive prompt on TTY, or piped stdin); `user promote` for admin grant without password reset. See [CLI reference](https://filebrowserquantum.com/en/docs/reference/cli/).
**Notes**:
- v2.x.x uses a new write-through backend state management. Changes go through a fast memory layer and also write changes to database to stay in sync. See [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/).
- CLI server start (`./filebrowser`), `setup`, `version`, and `set rule` syntax unchanged; see [CLI docs](https://filebrowserquantum.com/en/docs/reference/cli/)
- new dropdown and input styles
- swipe gestures to dismiss notifications (#2672)
- user updates are more granular, don't include entire user payload.
- `user.id` has been moved to a backend property and all frontend apis now query users by username. Swagger has been updated. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
- removed legacy and deprecated properties from API responses and generated config output
- `/api/media/stream` is audio/video only (range-based chunking). Non-media inline viewing uses `GET /api/resources/view`. Both endpoints use the same `viewToken` from file metadata. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
- removed exiftool as an optional helper, always built with the supported libraries (requires 64 bit os)
- If migration issues arise, see [Migration troubleshooting](https://filebrowserquantum.com/en/docs/getting-started/migration/troubleshooting/).
- default browser media player option removed, always uses themed plyr
- [docker] upgraded ffmpeg from 8.1.2 to 9.0
## v1.5.5
**BugFixes**:
- fix uncustomized (minimal) API tokens creation needed by webdav clients (#2503)
## v1.5.4
**Notes**:
- PWA installation name now capped at 30 characters instead of 12 (#2699)
**BugFixes**:
- fix forward slash blocked in text fields when the search shortcut listener intercepts `/` (#2696)
## v1.5.3
**BugFixes**:
- fix probe canShare with the real file, not a fixed text/plain stand-in (#2664)
- When the logout button is pressed, the user is redirected to an invalid URL that does not honor baseURL / externalUrl (#2657)
- fix redirect to login when an authenticated request returns 401 (expired session)
## v1.5.2
**Notes**:
- [docker] upgraded ffmpeg version 8.1.1 to 8.1.2
**BugFixes**:
- Fixed OnlyOffice reopening an older editor state after a document is modified (#2633) (#2578).
- Cannot "Reset and generate new two-factor code" to reset the TOTP for a user (#2399) (#2641).
## v1.5.1
**BugFixes**:
- fix http config section being dropped so trustedHeaders and disableRateLimit apply (#2602) (#2560)
- fix upload shares with password (#2589) (#2573)
- fix token when returning from preview on shares with pass (#2588) (#2465)
- fix share undefined url after editing (#2567) (#2523)
## v1.5.0
**New Features**:
- Added basic html viewer with relative reference support (#2522)
- Shares can have pinned files, requires a user to have edit access to share (#2522)
- Enhanced search
- now uses "lazy" match by default. (#2509)
- added missing `case sensitive` option in the UI
- Progressive Web App (PWA) improvements
- restored install prompt with sidebar install button (#2086)
- camera and video capture buttons on upload (mobile-friendly `capture` inputs)
- send files to other apps via the Web Share API (`Send to app` in the context menu)
- App notifications for file operations (#2478)
- optional browser notifications when uploads, chunked downloads, move/copy, or failures finish while the tab is in the background
- single on/off toggle in Notifications settings, stored in browser local storage
**Notes**:
- [docker] upgraded ffmpeg version 8.1 to 8.1.1
**BugFixes**:
- fixed PWA manifest `scope` and `id` so install works when the app is served under a base URL
- installing a public share as a PWA now opens the share URL instead of the site root (#2302)
## v1.4.4
**BugFixes**:
- Pinning/Unpinning file resets users' scope permission (#2532)
## v1.4.3
**Security**:
- [High] Path traversal in subtitle handler allows any authenticated user to read arbitrary files (GHSA-vvp7-h4fj-m28w)
**New Features**:
- pinned files/folders (#2396) (#2510)
- Markdown Image relative reference support (#2355)
- Add an option to create a new folder for unarchiving (#2338)
**Notes**:
- added fallback to show text in notification if copy fails (#2517)
- added `alt`+`arrow up` shortcut as alias of `backspace` to go into parent directory (#2501) (#2521)
- added `alt`+`arrow down` shortcut as alias of `enter` to open files in Listing View (#2501) (#2521)
- updated help menu with better translations
- migrate i18n to v11 (#2472) (#2504)
- migrate eslint (#2459)
- improved preview cancellation to improve performance when navigating UI.
**BugFixes**:
- fix lyrics regex parsing (#2505)
- fix folder previews issue (#2487) (#2492)
- fix accidental exit on images while using gestures (#2508)
- navigating using the up/down arrow keys in Listing View was "jumping randomly" in some view modes (#2521)
- socket field in config.yaml is ignored (#2497)
- fix keep opened file selected after closing its preview @anpryl (#2515)
## v1.4.2
**Security**:
- [Moderate] Add Rate Limiting on Authentication Endpoint Enables Brute Force Attacks (GHSA-r4v7-6wcg-ghj5)
- [Critical] Path traversal in public share PATCH allows file ops outside shared directory -- thanks @Revanth011 and @fg0x0 (GHSA-qqqm-5547-774x)
**New Features**:
- read-only source configuration via `source.config.readOnly: true` (#2438)
**Notes**:
- auth rate limiting can be disabled via `auth.disableRateLimit`
- updated share hash middleware (#2443)
- updated source info popup to include private and readOnly properties
**BugFixes**:
- Logout from share page now redirects to the share instead of `/Login` again. (#2245)
- `This location cannot be reached` error when navigating with FileTree in shares. (#2245)
- Fix FileTree rename and move actions in previews. (#2245)
- Delete prompt not showing date and thumbnails in some previews. (#2245)
- fix path slash issue on windows (#2451) (#2433) (#2419)
- Always force url rewrite for onlyoffice internal URL. Fixes Error saving with OnlyOffice (#2450)
- Overriding a Deny with an Allow not working (#2405)
## v1.4.1
**Security**:
- Fix critical: unauthenticated user can view source info
## v1.4.0
**New Features**:
- Add Passkey (WebAuthn) support for passwordless authentication (#2287) (#2379) -- thanks @juansoler
- Lyrics support for audio files (#2360):
- Updated UI for audio previews!
- The lyrics and the player gets syncronized in UI with the audio player.
- Supports embedded lyrics (ID3v2 USLT, flac, ogg, clyr) and sidecar `.lrc` files with the same name as the audio file.
- The `Q` shortcut in audio files will toggle the new panel visibility.
- `E` shortcut to change between tabs of the panel if it's open.
- Api log filtering support via `apiFilter` option for logging.
- regex filter that excludes matching full api paths from being logged. (eg. `/user\?id\=self`) Defaults to `^/health|^/favicon.ico|^/static|^/public/static`
- Add config option to disable /health endpoint logging (#2291)
- More shortcuts (#2300)
- `CTRL+F1` switch to normal view.
- `CTRL+F2` switch to gallery view.
- `CTRL+F3` switch to list view.
- `Double click` on empty space to select all items.
- Option to hide certain files in UI by extension (#2403) (#2277)
- Additional search features
- wildcard search similar to regex, but limited to sqlite GLOB syntax (#769)
- advanced search tool that shows all results in a listing thats sortable and actionable like a normal listing (#1051)
- advanced search supports multiple custom scopes per search
- multiple search terms with AND or OR logic support
**Notes**:
- Enhanced indexing scheduler which doesn't wake the disk as often.
- New API route `media/lyrics` used to fetch and parse lyrics (embedded or from `.lrc` sidecar) (#2360)
- Swiping down gesture in fullscreen videos exit fullscreen instead of close preview.
- Improved styles for path selection and tables
- Improved style of drag and drop into listing view (#2407)
- Edit Sidebar links has new "show tools in sidebar" toggle and all users have this enabled by default. can be disabled via for new users `userDefaults.sidebar.showTools: false`
- Update user defaults ordering (#1140)
- Save view modes and sizes into local storage instead of db (#2301)
**BugFixes**:
- Blue overlay when using gestures in video files on mobile (#2360)
- Playback queue wasn't updating when changing of folder (#2360)
- Added missing `UserDefaults.listing.showCopyPath` option to config file (#2364)
- Delete prompt thumbnail didn't worked when using it with the `delete` shortcut. (#2407)
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
- Drag and drop style state was stuck until page reload (#2407)
- Navigate close settings shows "something went wrong" (#2047)
## v1.3.10
**Notes**:
- When indexing is disabled for a source, the usage will always reported as partition size.
- removed deprecated `source.config.disableIndexing`, see [rules](https://filebrowserquantum.com/en/docs/advanced/source-configuration/conditional-rules/#disable-indexing)
**BugFixes**:
- Disable index option not working (#2385)
## v1.3.9
**Security**:
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
**BugFixes**:
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
## v1.3.8
**BugFixes**:
- Quick download icon style after icon change.
- Missing error popup for resource creatoin actions (upload/create)
- EnforcedOtp login failure until restart (#2330)
- Thumbnails for Folders only display sporadically (#2353)
- Unwanted user scope change for users with non-default scopes (#2347)
- Fix sidebar source info totals (#2321) (#2322) (#982)
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
## v1.3.7
**Notes**:
- creating/deleting password-based user requires reauthentication (#2112)
**BugFixes**:
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
- No Loginfields shown if password authentification is set to false (#2331)
## v1.3.6
**BugFixes**:
- file watcher not working for files and not respecting line count (#2314)
- "source" type link shows up multiple times when adding sidebar links if user has multiple sources.
- JWT ES256 results in error (#2188)
- single file shares not loading editor text content (#2122)
## v1.3.5
**Notes**:
- any password change or admin user property change for a password user requires reauthentication (#2112)
**BugFixes**:
- PWA icon fixes (#2292)
- deny-rule'd folders visible in directory listings (regression from v1.2.4-stable) (#2295)
- OTP and password requirement fixes (#2112) (#2263)
- CLI not picking up config properly
- Storage usage numbers on first load adjusted.
- Some items in userDefaults are not setting defaults for new users properly #2278
- Fix copy to clipboard and simplify code (#2281) (#2274)
- Fix search shortcut `/` and pdf previews (#2307)
- Special permissions bit not set when declared in createDirectoryPermission (#2283)
- Execute permission altered after editing sh script with FB Quantum (#2309)
- Source is red but working (#2289)
## v1.3.4
**New Features**:
- Epub placementg url anchors to bookmark a specific location on the doc.
- Add Requirement for Current Password When Changing Account Password (#2112)
- Copy file path to clipborad through right click (#2204)
**Notes**:
- user scope editing has path picker and filesystem validation
**BugFixes**:
- Swiping media files doesn't respect Playback Queue (#2243)
- Audio metadata and album thumbnails doesn't work (#2236)
- Search spinner wouldn't show up sometimes while searching
- The default landing page source should always be the first source in the sidebar, that wasn't always respected.
## v1.3.3
**New Features**:
@@ -64,7 +740,7 @@ All notable changes to this project will be documented in this file. For commit
- Cannot edit shared file in OnlyOffice #2143
- PWA install button disappeared (#2086)
- Deleting a root folder was possible #2128
- PUT resource api errors if action against a folder (#2153)
- PUT resource api errors if action against a folder (#2153)
- LDAP authentication issue if a password caontains @ symbol (#2154)
- Share banner seems to be not working for custom urls (#2120)
@@ -98,7 +774,7 @@ Note: a potentially breaking change for docker users: \the default user is now "
- New Sidebar Features
- Sidebar tree navigation (#2006) (#350)
- Source usage to be customized to show os-reported values rather than calculated. This can be changed per source by editing the source link in the sidebar. (#1266) (#982)
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
- new api to archive/unarchive files on the server
- requires `create` user permissions
- archiving actions respect `server.maxArchiveSize`
@@ -261,7 +937,7 @@ Note: a potentially breaking change for docker users: \the default user is now "
## v1.2.1-beta
**New Features**:
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
**Notes**:
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850)
@@ -364,7 +1040,7 @@ Sorry for the delay -- a lot of effort went into this release, specifically abou
- warning for low free space below 20GB
- warning if read/write latency is slow
- fatal for any errors reading or writing to the directory
- links to official docs for more info
- links to official docs for more info
- access rule changes:
- denied folders won't show up in parent directory listing view (#1684)
- tools will respect access rules
@@ -399,7 +1075,7 @@ Sorry for the delay -- a lot of effort went into this release, specifically abou
**BugFixes**:
- better index status updates, fixing delays #1649
- fixed long load times for listings with media info due sequential processing of files.
- fixed long load times for listings with media info due sequential processing of files.
- downloaded files always included `utf-8` in filename #1671
- custom sidebar links allow external links like `https://google.com`
- html title not populated correctly for links #1676
+9 -70
View File
@@ -5,13 +5,13 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
## Prerequisites
- **Go 1.25+** (see `backend/go.mod`)
- **Node.js 20.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Docker** (optional, for containerized development)
- **Git** especially improtant on windows -- needed for bash support.
- **Git** especially important on Windows -- needed for bash support.
### Optional Tools
- **ffmpeg**: For media features (subtitles, thumbnails, heic)
- **ffmpeg/ffprobe**: For media features (subtitles, thumbnails, heic)
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
@@ -42,11 +42,12 @@ make dev
### Backend (Go)
- **Entry Point**: `backend/main.go` → `backend/cmd/`
- **HTTP Server**: `backend/http/` - API routes, middleware, auth
- **Storage**: BoltDB via `backend/database/storage/`
- **Authentication**: Multiple providers in `backend/auth/`
- **Indexing**: Real-time search in `backend/indexing/`
- **Previews**: Image/video/document generation in `backend/preview/`
- **HTTP Server**: `backend/internal/web/` - API routes, middleware, auth
- **Storage**: SQLite via `backend/internal/database/`
- **Authentication**: Multiple providers in `backend/internal/auth/`
- **Indexing**: Real-time search in `backend/pkg/indexing/`
- **Previews**: Image/video/document generation in `backend/internal/preview/`
- **Activity**: Query, recording, and export logic in `backend/internal/activity/`
### Frontend (Vue.js + TypeScript)
- **Framework**: Vue 3 + Vite + TypeScript
@@ -90,18 +91,6 @@ make test-playwright # E2E tests in Docker
E2E tests run with three authentication modes: standard auth, no auth, and proxy auth.
### Coverage & Performance
```bash
cd backend
./run_check_coverage.sh # Coverage report with HTML output
./run_benchmark.sh # Benchmarks
```
**Code Coverage:**
- View report: Open `backend/coverage.html` after running coverage script
- CI enforces coverage for critical packages
- Use `go test -cover` for quick package coverage
## Code Standards
### Backend (Go)
@@ -156,53 +145,3 @@ type(scope): description
Types: feat, fix, docs, refactor, test, chore
```
## Roadmap
Check the [Project Roadmap](https://github.com/users/gtsteffaniak/projects/4/views/2) to see issues sorted by priority. This helps you understand what features are planned and where you can contribute most effectively.
## Troubleshooting
### Common Issues
**Build failures:**
```bash
# Frontend
cd frontend && rm -rf node_modules && npm install
# Backend
cd backend && go mod tidy && go clean -modcache
```
**Authentication Issues:**
Always first enable debug logging in your config file if you have issues.
1. **OIDC Login Fails:**
- Check redirect URLs match your config
- Verify OIDC provider settings
- Enable debug logs to see auth flow
- Common issue: mismatched callback URLs
2. **Proxy Auth Not Working:**
- Verify header names match config
- Check nginx/reverse proxy passes headers
- Test with: `curl -H "X-Auth: username" localhost:8080`
- Enable `--log-level debug` to see headers
3. **TOTP Issues:**
- Ensure server time is synchronized
- Check QR code generation in logs
- Test with authenticator app time settings
- Database might have stale TOTP secrets
4. **Session/Cookie Problems:**
- Clear browser cookies and localStorage
- Verify cookie domain settings
- Try incognito/private browsing mode
### Getting Help
- **Wiki**: [Project Wiki](https://github.com/gtsteffaniak/filebrowser/wiki)
- **Issues**: [GitHub Issues](https://github.com/gtsteffaniak/filebrowser/issues)
- **PR Template**: See `.github/PULL_REQUEST_TEMPLATE.md`
+25 -26
View File
@@ -1,26 +1,25 @@
<div align="center">
[![Go Report Card](https://goreportcard.com/badge/github.com/gtsteffaniak/filebrowser/backend)](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/0b548794f2ac4871a0cf7aa9ecab049f)](https://app.codacy.com/gh/gtsteffaniak/filebrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
[![latest version](https://img.shields.io/github/v/release/gtsteffaniak/filebrowser)](https://github.com/gtsteffaniak/filebrowser/releases)
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A#is-there-a-way-to-donate-or-support-this-project)
<img width="150" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" title="Logo">
<img width="150" alt="FileBrowser Quantum logo" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174">
<h3>FileBrowser Quantum</h3>
The best free self-hosted web-based file manager.
<br/><br/>
<img width="800" src="https://github.com/user-attachments/assets/162d7a95-33b7-49bd-976c-dd6822c0d22b">
<img width="800" alt="FileBrowser Quantum file listing in dark mode" src="https://filebrowserquantum.com/images/generated/listing/view-mode-normal-dark.jpg">
</div>
## Pinned
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
: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
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has a modern responsive interface that has many advanced features to manage users, access, sharing, and file preview and editing.
@@ -39,21 +38,23 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
2. ✅ Login support for OIDC, LDAP, JWT, password + 2FA, and proxy.
3. ✅ Beautiful, Responsive, and Customizable user interface.
4. ✅ Streamlined configuration via `config.yaml` config file.
5. ✅ Ultra-efficient Sqlite [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing)
5. ✅ Efficient search
- Real-time search results as you type.
- Real-time monitoring and updates in the UI.
- Search supports file and folder sizes, along with various filters.
6. ✅ Better listing browsing
- Better thumbnail support including **office**, **video**, and **album artwork**, and **3D models**.
- Thumbnails support includes **office**, **video**, and **album artwork**, and **3D models**.
- Faster and more responsive views with animations.
- Folder sizes are displayed and support thumbnails
- **Folder sizes** are displayed and support thumbnails
- Navigating remembers the last scroll position.
- WebDAV support
- Granular permissions
7. ✅ Highly configurable and [customizable sharing options](https://filebrowserquantum.com/en/docs/shares/options/)
- share expiration time
- users who can access share (including anonymous)
- styling and themes
- file viewing, editing, and uploading permissions
8. ✅ Directory-level access control that can be scoped to user or group.
8. ✅ Access control that can be scoped to user or group and source path.
9. ✅ Developer API support
- Ability to create long-lived API Tokens.
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
@@ -68,26 +69,24 @@ FileBrowser Quantum differs significantly from the original version. Many of the
The UI has a simple three-component navigation system:
1. (Left) Multi-action button with slide-out panel.
2. (Middle) The powerful search bar.
3. (Right) The view change toggle.
2. (Middle) The powerful search bar / title
3. (Right) The view change toggle / overflow menu
All other functions are moved either into the action menu or pop-up menus.
If the action does not depend on context, it will exist in the slide-out
action panel. If the action is available based on context, it will show up as
a pop-up menu.
<p align="center">
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
</p>
<img width="1200" height="750" alt="Aug-07-2026 14-54-55" src="https://github.com/user-attachments/assets/fd1c1747-f30f-4e21-b991-f45744416d7e" />
## Official Docs
See the [Official Docs](https://filebrowserquantum.com/). Contributions are welcome and encouraged! See [FilebrowserDocs Github](https://github.com/quantumx-apps/filebrowserDocs).
## Comparison Chart
Application Name | <img width="48" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
Application Name | <img width="48" alt="" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" alt="" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" alt="" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" alt="" src="https://cdn.iconscout.com/icon/free/png-256/free-google-drive-logo-icon-svg-download-png-2476481.png" > Google_Drive | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Filesystem support | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
@@ -96,7 +95,7 @@ Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
@@ -110,25 +109,25 @@ Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
Metrics | :construction: | ❌ | ✅ | ✅ | ❌ | ❌ |
file space quotas | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Themes | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
activity log | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
+13 -12
View File
@@ -1,33 +1,35 @@
FROM gtstef/ffmpeg:8.1-decode AS ffmpeg
FROM gtstef/ffmpeg:9.0.2-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev
RUN go mod download && go run ./scripts/setup-gofitz-cgo
ENV CGO_ENABLED=1
RUN go build -tags mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:jod-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
WORKDIR /build
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN cd frontend && npm ci --maxsockets 1
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
FROM alpine:latest
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
@@ -36,12 +38,11 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]
RUN [ "exiftool", "-ver" ]
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+4 -2
View File
@@ -1,5 +1,7 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps chromium
COPY frontend/package-lock.json /tmp/package-lock.json
RUN PW_VERSION=$(node -p "require('/tmp/package-lock.json').packages['node_modules/@playwright/test'].version") \
&& npm i "@playwright/test@${PW_VERSION}" oauth2-mock-server @types/node
RUN npx playwright install --with-deps chromium-headless-shell
+4 -5
View File
@@ -9,15 +9,14 @@ COPY [ "./_docker/src/general/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# generate duplicate 1.1MB files
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
RUN ./filebrowser user set testuser1 --password testuser1 && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+2 -2
View File
@@ -5,8 +5,8 @@ WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
+14 -6
View File
@@ -2,13 +2,21 @@ FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
COPY [ "./_docker/src/settings/backend/config.yaml", "./"]
COPY [ "./_docker/src/settings/backend/database.db.old", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
ENTRYPOINT [ "./filebrowser" ]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser user set testuser1 --password testuser1
RUN ./filebrowser set rule -s access -p / -r user -v admin --allow -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded/showme.txt -r user -v admin --allow -c config.yaml
RUN ./filebrowser set rule -s access -p /denied -r user -v admin -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
ENTRYPOINT [ "./filebrowser" ]
+4 -3
View File
@@ -9,10 +9,11 @@ COPY [ "./_docker/src/no-config/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
ENV FILEBROWSER_ADMIN_PASSWORD=playwright-password
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+2 -2
View File
@@ -10,8 +10,8 @@ COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+2 -2
View File
@@ -4,10 +4,10 @@ COPY [ "./_docker/src/oidc/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
RUN mkdir -p ./http/dist
RUN mkdir -p ./internal/web/dist
COPY [ "./_docker/src/oidc/backend/", "./"]
COPY ["./backend/filebrowser", "./"]
COPY [ "./backend/http/embed/", "./http/dist/"]
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
WORKDIR /app/frontend
+12
View File
@@ -0,0 +1,12 @@
# Local listing-performance only (see docker-compose local-playwright-performance).
# CI uses ghcr.io/gtsteffaniak/playwright-base:chromium + Dockerfile.playwright-performance.
FROM node:22-slim
WORKDIR /app/frontend
COPY frontend/package-lock.json /tmp/package-lock.json
RUN PW_VERSION=$(node -p "require('/tmp/package-lock.json').packages['node_modules/@playwright/test'].version") \
&& npm i "@playwright/test@${PW_VERSION}"
ARG PLAYWRIGHT_BROWSERS=chromium,firefox,webkit
ENV PLAYWRIGHT_BROWSERS=${PLAYWRIGHT_BROWSERS}
RUN bash -ec 'set -euo pipefail; for b in ${PLAYWRIGHT_BROWSERS//,/ }; do npx playwright install --with-deps "$b"; done'
+52
View File
@@ -0,0 +1,52 @@
# CI: same ghcr.io playwright-base:chromium + GHA cache as screenshots/previews (chromium only).
# Local all-browser runs: make playwright-perf-base + compose PLAYWRIGHT_BASE_IMAGE.
ARG PLAYWRIGHT_BASE_IMAGE=ghcr.io/gtsteffaniak/playwright-base:chromium
FROM ${PLAYWRIGHT_BASE_IMAGE} AS perf
WORKDIR /app
COPY [ "./_docker/src/performance/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests/", "./tests" ]
COPY [ "./frontend/playwright.performance.config.ts", "./playwright.config.ts" ]
# The base image's /app/frontend/package.json is generated by `npm i` and has no
# "type" field, so /app/frontend is a CommonJS package. Playwright transpiles the
# suite accordingly (emitting `exports.x = ...`), but its ESM loader reparses these
# files as ES modules because their syntax is unambiguous ESM. The result is
# "ReferenceError: exports is not defined in ES module scope" before a single test
# runs. The perf harness is real ESM, so declare it as such — this matches
# frontend/package.json in the repo (also "type": "module").
RUN node -e 'const fs=require("fs");const p="package.json";const j=JSON.parse(fs.readFileSync(p,"utf8"));j.type="module";fs.writeFileSync(p,JSON.stringify(j,null,2)+"\n")'
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
ENV CI=true
ENV PERF_BASE_URL=http://127.0.0.1/
ENV PERF_SCALES=100,1000,10000
ENV PERF_WORKERS=6
ENV PERF_TEST_TIMEOUT_MS=480000
# CI runs a single pass per scenario (N=1) for fast PR feedback; local runs
# default to N=3 and take the median. The baseline records the worker count and
# this setting, so an incomparable comparison is reported rather than silently
# producing a bogus regression.
ENV PERF_REPEATS=1
# Stamped into the baseline environment fingerprint so a mismatch is detectable.
ENV PERF_IMAGE_TAG=playwright-base-chromium
# Lets the harness report container-specific remedies (e.g. rebuild the base
# image) instead of host-oriented advice when a browser is missing.
ENV PERF_IN_CONTAINER=1
# CI / make test-playwright-performance (chromium only — same base as other Playwright images)
FROM perf AS ci
ENV PERF_BROWSERS=chromium
# The committed baseline (frontend/tests/playwright/performance/perf-baseline.json)
# is copied in by the `COPY ./frontend/tests/` layer above. This step exits
# non-zero when a gated metric regresses beyond its tolerance, which is what
# makes the performance check gate the PR.
RUN ./filebrowser & sleep 5 && cd ../frontend && npx playwright test -c playwright.config.ts
+2 -2
View File
@@ -9,8 +9,8 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+2 -2
View File
@@ -5,8 +5,8 @@ WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
+6 -5
View File
@@ -2,14 +2,15 @@ FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
COPY [ "./frontend/tests", "./tests" ]
COPY [ "./_docker/src/screenshots/frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./backend/filebrowser", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
+9 -6
View File
@@ -10,13 +10,16 @@ COPY [ "./frontend/tests/", "./tests" ]
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser set rule -f /tests/playwright-files -p / -r user -v admin -allow -c config.yaml
RUN ./filebrowser set rule -f /tests/playwright-files -p /excluded -r user -v admin -c config.yaml
RUN ./filebrowser user set testuser1 --password testuser1
RUN ./filebrowser set rule -s access -p / -r user -v admin --allow -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded/showme.txt -r user -v admin --allow -c config.yaml
RUN ./filebrowser set rule -s access -p /denied -r user -v admin -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+3 -3
View File
@@ -9,9 +9,9 @@ COPY [ "./_docker/src/sharing/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+10 -9
View File
@@ -4,23 +4,24 @@ ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
RUN go build -tags analytics -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:jod-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
WORKDIR /build
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN npm i
COPY ./frontend/ ./
RUN cd frontend && npm ci
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl
@@ -31,7 +32,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
# exposing default port for auto discovery.
+37 -1
View File
@@ -12,8 +12,44 @@ services:
- ../frontend/generated:/app/frontend/generated
entrypoint: ["/bin/sh", "-c", "./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots && npx playwright test --project light-screenshots"]
build:
dockerfile: ./_docker/Dockerfile.playwright-local
dockerfile: ./_docker/Dockerfile.playwright-screenshots
context: ../
local-playwright-performance:
container_name: local-playwright-performance
working_dir: /app/backend
volumes:
- ../frontend/test-results/playwright-performance:/app/frontend/test-results/playwright-performance
- ../frontend/test-results/listing-performance-perf:/app/frontend/test-results/listing-performance-perf
- ../frontend/tests/playwright/performance:/out/report
environment:
PERF_RESULTS_DIR: /app/frontend/test-results/listing-performance-perf
PERF_REPORT_DIR: /out/report
PERF_GREP: ${PERF_GREP:-}
PERF_BROWSERS: ${PERF_BROWSERS:-}
# Local runs repeat each scenario and take the median to average out noise.
# CI overrides this to 1 for fast feedback.
PERF_REPEATS: ${PERF_REPEATS:-3}
# Set to 1 to rewrite the committed chromium baseline from this run.
PERF_UPDATE_BASELINE: ${PERF_UPDATE_BASELINE:-0}
# Local comparisons are advisory unless explicitly enforced.
PERF_ENFORCE_BASELINE: ${PERF_ENFORCE_BASELINE:-0}
PERF_IMAGE_TAG: ${PERF_IMAGE_TAG:-local}
PERF_IN_CONTAINER: "1"
# When PERF_UPDATE_BASELINE=1, write through the mounted report dir (host:
# frontend/tests/playwright/performance/perf-baseline.json).
PERF_BASELINE_PATH: /out/report/perf-baseline.json
entrypoint:
[
"/bin/sh",
"-c",
"./filebrowser & sleep 5 && cd ../frontend && npx playwright test -c playwright.config.ts $${PERF_GREP:+-g \"$$PERF_GREP\"}",
]
build:
dockerfile: ./_docker/Dockerfile.playwright-performance
target: perf
context: ../
args:
PLAYWRIGHT_BASE_IMAGE: ${PLAYWRIGHT_BASE_IMAGE:-filebrowser-playwright-perf-base:latest}
nginx-proxy-auth:
image: nginx
container_name: nginx-proxy-auth
+8 -3
View File
@@ -1,8 +1,13 @@
server:
<<: *server-main
http:
<<: *http-main
frontend:
<<: *frontend-main
auth:
<<: *auth-main
userDefaults:
preview:
image: true
@@ -12,6 +17,6 @@ userDefaults:
models: true
office: true
highQuality: true
permissions:
download: true
create: true
account:
permissions:
share: true
+16 -4
View File
@@ -1,15 +1,27 @@
server-main: &server-main
http-main: &http-main
port: 80
baseURL: "/"
server-main: &server-main
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: true
create: true
- path: "."
name: "docker"
config:
disableIndexing: true
defaultEnabled: true
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
auth-main: &auth-main
adminPassword: "playwright-password"
+17 -12
View File
@@ -1,12 +1,19 @@
server:
http:
port: 8080
server:
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: true
defaultPermissions:
view: true
download: true
modify: true
create: true
frontend:
name: "Graham's Filebrowser"
@@ -30,19 +37,17 @@ auth:
groupsClaim: "mygroup"
adminGroup: "admin"
userDefaults:
ui:
darkMode: true
account:
disableSettings: false
permissions:
share: true
listing:
singleClick: false
preview:
image: true
popup: true
video: true
office: true
highQuality: true
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: true
api: false
download: true
create: true
+19 -17
View File
@@ -1,13 +1,20 @@
server:
http:
port: 80
baseURL: "/files/"
server:
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "exclude"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: true
create: true
rules:
- folderPath: "excluded"
- folderPath: "excludedButVisible"
@@ -27,16 +34,13 @@ server:
- filePath: "/subfolderExclusions/exclusionlist.sh"
- folderPath: "/subfolderExclusions/folderItem"
viewable: true
conditionals:
rules:
- fileStartsWith: "startsWith"
- folderEndsWith: "Folder"
- folderNames: 'folderNames'
- fileNames: 'fileNames'
- folderName: 'folderName'
- fileName: 'fileName'
- folderName: '.hiddenDir'
- fileName: '.hidden'
- fileStartsWith: "startsWith"
- folderEndsWith: "Folder"
- folderName: "folderName"
- fileName: "fileName"
- fileName: "fileNames"
- folderName: ".hiddenDir"
- fileName: ".hidden"
auth:
methods:
@@ -53,13 +57,11 @@ frontend:
disableEventThemes: true
userDefaults:
showHidden: true
listing:
showHidden: true
preview:
image: true
popup: true
video: true
office: true
highQuality: true
permissions:
download: true
create: true
highQuality: true
+16 -10
View File
@@ -1,11 +1,17 @@
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: true
defaultPermissions:
view: true
download: true
modify: true
create: true
frontend:
name: "Graham's Filebrowser"
@@ -35,13 +41,13 @@ userDefaults:
video: true
office: true
highQuality: true
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: false
api: false
download: true
create: true
ui:
darkMode: true
listing:
singleClick: false
account:
disableSettings: false
permissions:
admin: false
share: false
api: false
@@ -0,0 +1,20 @@
server:
<<: *server-main
http:
<<: *http-main
frontend:
<<: *frontend-main
auth:
<<: *auth-main
userDefaults:
preview:
image: true
popup: true
video: true
audio: true
models: true
office: true
highQuality: true
account:
permissions:
share: true
@@ -0,0 +1,13 @@
frontend-main: &frontend-main
name: "Graham's Filebrowser"
disableDefaultLinks: true
styling:
disableEventThemes: true
customThemes:
no-rounded:
description: "No rounded corners" # The description of the theme to display in the UI.
css: "no-rounded.css" # The css file path and filename to use for the theme.
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
@@ -0,0 +1,27 @@
http-main: &http-main
port: 80
baseURL: "/"
server-main: &server-main
logging:
- levels: "warning|error"
apiLevels: disabled
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: true
create: true
- path: "."
name: "docker"
config:
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
auth-main: &auth-main
adminPassword: "playwright-password"
+12 -6
View File
@@ -1,10 +1,18 @@
server:
http:
port: 80
baseURL: "/"
server:
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultPermissions:
view: true
download: true
modify: true
create: true
auth:
methods:
@@ -21,7 +29,8 @@ frontend:
disableEventThemes: true
userDefaults:
showHidden: true
listing:
showHidden: true
preview:
image: true
popup: true
@@ -30,6 +39,3 @@ userDefaults:
office: true
models: true
highQuality: true
permissions:
download: true
create: true
+14 -19
View File
@@ -1,13 +1,21 @@
server:
http:
port: 8080
baseURL: "/subpath"
baseURL: "/subpath"
trustProxyHeaders: true
server:
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: true
defaultPermissions:
view: true
download: true
modify: true
create: true
frontend:
name: "Graham's Filebrowser"
@@ -29,19 +37,6 @@ auth:
logoutRedirectUrl: "https://localhost/api/auth/logout2"
userDefaults:
preview:
image: true
popup: true
video: true
office: true
highQuality: true
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: true
api: false
download: true
create: true
account:
permissions:
share: true
+14 -2
View File
@@ -1,10 +1,22 @@
server:
http:
port: 8080
baseURL: "/files/"
auth:
adminPassword: "playwright-password"
server:
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright"
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
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
/**
* Read environment variables from file.
@@ -9,16 +10,16 @@ import { defineConfig, devices } from "@playwright/test";
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
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,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
retries: 1,
/* Opt out of parallel tests on CI. */
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
@@ -53,8 +54,6 @@ export default defineConfig({
...devices["Desktop Chrome"],
theme: 'dark',
},
testMatch: /.*screenshots.spec.ts/,
retries: 0,
},
{
name: "light-screenshots",
@@ -62,8 +61,6 @@ export default defineConfig({
...devices["Desktop Chrome"],
theme: 'light',
},
testMatch: /.*screenshots.spec.ts/,
retries: 0,
},
],
});
+17 -4
View File
@@ -1,18 +1,26 @@
server:
http:
port: 80
baseURL: "/"
server:
database:
migrateFrom: "database.db.old"
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
defaultPermissions:
modify: false
- path: "."
name: "docker"
config:
disableIndexing: true
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
- path: "/tests/playwright-files"
name: "access"
config:
@@ -33,6 +41,11 @@ frontend:
title: "Playwright"
auth:
adminPassword: "playwright-password"
methods:
password:
signup: true
signup: true
userDefaults:
listing:
showHidden: true
Binary file not shown.
@@ -11,7 +11,7 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/global-setup",
timeout: 3000,
timeout: 5000,
testDir: "./tests/playwright/settings",
/* Run tests in files in parallel */
fullyParallel: false,
+7 -2
View File
@@ -1,8 +1,10 @@
server:
http:
port: 80
baseURL: "/"
server:
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
@@ -14,6 +16,9 @@ server:
private: true
defaultEnabled: true
auth:
adminPassword: "playwright-password"
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
+1
View File
@@ -0,0 +1 @@
FILEBROWSER_DEVMODE=true
+11 -4
View File
@@ -1,15 +1,22 @@
root = "."
tmp_dir = "tmp"
env_files = [".air.env"]
[build]
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=version'\" ."
bin = "tmp/filebrowser"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
cmd = "bash ./scripts/air-build.sh"
entrypoint = "./tmp/filebrowser"
args_bin = ["-c", "test_config.yaml"]
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs", "preview/thumbnails", "preview/heic"]
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs", "internal/preview/gofitzinclude"]
poll = true
poll_interval = 1000
log = "air.log"
delay = 1000 # ms
[build.windows]
cmd = "bash ./scripts/air-build.sh"
entrypoint = "./tmp/filebrowser.exe"
[log]
time = true
-21
View File
@@ -1,21 +0,0 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/filebrowser.exe --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=version'\" ."
bin = "tmp/filebrowser.exe"
args_bin = ["-c", "test_config.yaml"]
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "http\\dist", "http\\embed", "swagger\\docs"]
exclude_unchanged = true
follow_symlink = false
poll = true
poll_interval = 1000
log = "air.log"
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = false
+34 -3
View File
@@ -2,6 +2,11 @@
project_name: filebrowser
version: 2
before:
hooks:
- go mod download
- go run ./scripts/setup-gofitz-cgo
builds:
# Build configuration for linux amd64
- id: linux-amd64
@@ -9,10 +14,11 @@ builds:
- CGO_ENABLED=1
- CC=zig cc -target x86_64-linux-musl
tags:
- analytics
- mupdf
- musl
ldflags: &ldflags
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/common/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA={{ .ShortCommit }}
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
goos:
@@ -32,6 +38,7 @@ builds:
- CGO_ENABLED=1
- CC=zig cc -target aarch64-linux-musl
tags:
- analytics
- mupdf
- musl
goos:
@@ -42,11 +49,29 @@ builds:
post:
- upx {{ .Path }}
# Build configuration for linux riscv64
- id: linux-riscv64
ldflags: *ldflags
main: main.go
binary: filebrowser
env:
- CC=zig cc -target riscv64-linux-musl
# go-fitz does not provide riscv64 MuPDF libraries yet: https://github.com/gen2brain/go-fitz/tree/master/libs
tags:
- analytics
- musl
goos:
- linux
goarch:
- riscv64
# Build configuration for linux
- id: linux-arm32
ldflags: *ldflags
main: main.go
binary: filebrowser
tags:
- analytics
goos:
- linux
goarch:
@@ -62,8 +87,10 @@ builds:
- id: windows
env:
- CGO_ENABLED=1
- CC=zig cc -target x86_64-windows
# windows-gnu avoids zig rejecting Go's -tsaware when cross-compiling from macOS
- CC=zig cc -target x86_64-windows-gnu -lc
tags:
- analytics
- mupdf
ldflags: *ldflags
main: main.go
@@ -81,16 +108,20 @@ builds:
ldflags: *ldflags
main: main.go
binary: filebrowser
tags:
- analytics
goos:
- freebsd
goarch:
- amd64
# Build configuration for macos without upx
# Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported)
- id: macos
ldflags: *ldflags
main: main.go
binary: filebrowser
tags:
- analytics
goos:
- darwin
goarch:
-74
View File
@@ -1,74 +0,0 @@
package files
import (
"fmt"
"os"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// MakeUserDir makes the user directory according to settings.
func MakeUserDir(fullPath string) error {
if err := os.MkdirAll(fullPath, os.ModePerm); err != nil {
return err
}
return nil
}
func MakeUserDirs(u *users.User, disableScopeChange bool) error {
cleanedUserName := users.CleanUsername(u.Username)
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
}
for i, scope := range u.Scopes {
source, ok := settings.Config.Server.SourceMap[scope.Name]
if !ok {
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
continue
}
// create directory and append user name
if filepath.Base(scope.Scope) != cleanedUserName && !disableScopeChange {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
if !Exists(parentDir) {
if err := MakeUserDir(parentDir); err != nil {
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
continue
}
}
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else if filepath.Base(scope.Scope) == cleanedUserName {
// create directory exactly as specified
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if !Exists(parentDir) {
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
continue
}
err := MakeUserDir(fullPath)
if err != nil {
logger.Errorf("create user: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else {
// just assigning scope to path provided, so just check that it exists
path := filepath.Join(source.Path, scope.Scope)
if !Exists(path) {
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
continue
}
}
u.Scopes[i] = scope
}
return nil
}
-206
View File
@@ -1,206 +0,0 @@
package fileutils
import (
"io"
"os"
"path"
"path/filepath"
"github.com/gtsteffaniak/go-logger/logger"
)
var PermFile os.FileMode
var PermDir os.FileMode
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
PermFile = PermFileOctal
PermDir = PermDirOctal
}
// MoveFile moves a file from src to dst.
// By default, the rename system call is used. If src and dst point to different volumes,
// the file copy is used as a fallback.
func MoveFile(src, dst string) error {
err := os.Rename(src, dst)
if err == nil {
return nil
}
// fallback
err = CopyFile(src, dst)
if err != nil {
logger.Errorf("CopyFile failed %v", err)
return err
}
go func() {
err = os.RemoveAll(src)
if err != nil {
logger.Errorf("os.Remove failed %v", err)
}
}()
return nil
}
// CopyFile copies a file or directory from source to dest and returns an error if any.
func CopyFile(source, dest string) error {
// Check if the source exists and whether it's a file or directory.
info, err := os.Stat(source)
if err != nil {
return err
}
if info.IsDir() {
// If the source is a directory, copy it recursively.
return copyDirectory(source, dest)
}
// If the source is a file, copy the file.
return copySingleFile(source, dest)
}
// copySingleFile handles copying a single file.
func copySingleFile(source, dest string) error {
// Get source file info to preserve permissions
srcInfo, err := os.Stat(source)
if err != nil {
return err
}
sourcePerms := srcInfo.Mode().Perm()
// Open the source file.
src, err := os.Open(source)
if err != nil {
return err
}
defer src.Close()
// Create the destination directory if needed.
err = os.MkdirAll(filepath.Dir(dest), PermDir)
if 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 {
return err
}
defer dst.Close()
// Copy the contents of the file.
_, err = io.Copy(dst, src)
if err != nil {
return err
}
// Preserve source file permissions
// Handle chmod errors gracefully (e.g., in rootless containers where chmod may be restricted)
err = os.Chmod(dest, sourcePerms)
if err != nil {
// Log but don't fail - chmod may be restricted in some environments
// The file was copied successfully, so we continue
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
}
return nil
}
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
// Create the destination directory.
err := os.MkdirAll(dest, PermDir)
if err != nil {
return err
}
// Read the contents of the source directory.
entries, err := os.ReadDir(source)
if err != nil {
return err
}
// Iterate over each entry in the directory.
for _, entry := range entries {
srcPath := filepath.Join(source, entry.Name())
destPath := filepath.Join(dest, entry.Name())
if entry.IsDir() {
// Recursively copy subdirectories.
err = copyDirectory(srcPath, destPath)
if err != nil {
return err
}
} else {
// Copy files.
err = copySingleFile(srcPath, destPath)
if err != nil {
return err
}
}
}
return nil
}
// CommonPrefix returns the common directory path of provided files.
func CommonPrefix(sep byte, paths ...string) string {
// Handle special cases.
switch len(paths) {
case 0:
return ""
case 1:
return path.Clean(paths[0])
}
// Treat string as []byte, not []rune as is often done in Go.
c := []byte(path.Clean(paths[0]))
// Add a trailing sep to handle the case where the common prefix directory
// is included in the path list.
c = append(c, sep)
// Ignore the first path since it's already in c.
for _, v := range paths[1:] {
// Clean up each path before testing it.
v = path.Clean(v) + string(sep)
// Find the first non-common byte and truncate c.
if len(v) < len(c) {
c = c[:len(v)]
}
for i := 0; i < len(c); i++ {
if v[i] != c[i] {
c = c[:i]
break
}
}
}
// Remove trailing non-separator characters and the final separator.
for i := len(c) - 1; i >= 0; i-- {
if c[i] == sep {
c = c[:i]
break
}
}
return string(c)
}
func ClearCacheDir(cacheDir string) {
entries, err := os.ReadDir(cacheDir)
if err != nil {
logger.Errorf("failed clear cache dir: %v", err)
}
for _, entry := range entries {
path := filepath.Join(cacheDir, entry.Name())
err = os.RemoveAll(path)
if err != nil {
logger.Errorf("failed clear cache dir: %v", err)
}
}
}
@@ -1,46 +0,0 @@
package fileutils
import "testing"
func TestCommonPrefix(t *testing.T) {
testCases := map[string]struct {
paths []string
want string
}{
"same lvl": {
paths: []string{
"/home/user/file1",
"/home/user/file2",
},
want: "/home/user",
},
"sub folder": {
paths: []string{
"/home/user/folder",
"/home/user/folder/file",
},
want: "/home/user/folder",
},
"relative path": {
paths: []string{
"/home/user/folder",
"/home/user/folder/../folder2",
},
want: "/home/user",
},
"no common path": {
paths: []string{
"/home/user/folder",
"/etc/file",
},
want: "",
},
}
for name, tt := range testCases {
t.Run(name, func(t *testing.T) {
if got := CommonPrefix('/', tt.paths...); got != tt.want {
t.Errorf("CommonPrefix() = %v, want %v", got, tt.want)
}
})
}
}
-121
View File
@@ -1,121 +0,0 @@
package auth
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// JSONAuth is a json implementation of an Auther.
type JSONAuth struct {
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
}
// Auth authenticates the user via a json in content body.
func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
password := r.Header.Get("X-Password")
// URL-decode password to support special characters in headers
password, err := url.QueryUnescape(password)
if err != nil {
return nil, fmt.Errorf("invalid password encoding: %v", err)
}
totpCode := r.Header.Get("X-Secret")
// If ReCaptcha is enabled, check the code.
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
ok, err := auther.ReCaptcha.Ok(recaptcha) //nolint:govet
if err != nil {
return nil, err
}
if !ok {
return nil, os.ErrPermission
}
}
user, getErr := userStore.Get(username)
var passwordHash string
if getErr != nil {
passwordHash = utils.InvalidPasswordHash
} else {
passwordHash = user.Password
}
// always run checkPwd to prevent timing attacks
err = utils.CheckPwd(password, passwordHash)
if err != nil {
return nil, err
}
if getErr != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
// check for OTP for password
if user.TOTPSecret != "" {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
}
err = VerifyTotpCode(user, totpCode, userStore)
if err != nil {
return nil, err
}
}
if user.LoginMethod != users.LoginMethodPassword {
return nil, errors.ErrWrongLoginMethod
}
return user, nil
}
const reCaptchaAPI = "/recaptcha/api/siteverify"
// ReCaptcha identifies a recaptcha connection.
type ReCaptcha struct {
Host string `json:"host"`
Key string `json:"key"`
Secret string `json:"secret"`
}
// Ok checks if a reCaptcha responde is correct.
func (r *ReCaptcha) Ok(response string) (bool, error) {
body := url.Values{}
body.Set("secret", r.Secret)
body.Add("response", response)
client := &http.Client{}
resp, err := client.Post(
r.Host+reCaptchaAPI,
"application/x-www-form-urlencoded",
strings.NewReader(body.Encode()),
)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, nil
}
var data struct {
Success bool `json:"success"`
}
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
return false, err
}
return data.Success, nil
}
-18
View File
@@ -1,18 +0,0 @@
package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodNoAuth is used to identify no auth.
const MethodNoAuth = "noauth"
// NoAuth is no auth implementation of auther.
type NoAuth struct{}
// Auth uses authenticates user 1.
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return usr.Get(uint(1))
}
-28
View File
@@ -1,28 +0,0 @@
package auth
import (
"net/http"
"os"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodProxyAuth is used to identify no auth.
const MethodProxyAuth = "proxy"
// ProxyAuth is a proxy implementation of an auther.
type ProxyAuth struct {
Header string `json:"header"`
}
// Auth authenticates the user via an HTTP header.
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
username := r.Header.Get(a.Header)
user, err := usr.Get(username)
if err == errors.ErrNotExist {
return nil, os.ErrPermission
}
return user, err
}
+252
View File
@@ -0,0 +1,252 @@
module github.com/gtsteffaniak/filebrowser/backend/build
go 1.27.0
require (
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
codeberg.org/chavacava/garif v0.2.1 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/exhaustruct/v5 v5.2.0 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
github.com/4meepo/tagalign v1.4.4 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
github.com/Antonboom/errname v1.1.2 // indirect
github.com/Antonboom/nilnil v1.1.2 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
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/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.4 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bits-and-blooms/bitset v1.25.0 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
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.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
github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
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/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.14.0 // indirect
github.com/dave/dst v0.28.0 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2/v2 v2.8.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.8 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v1.0.1 // indirect
github.com/go-critic/go-critic v0.15.0 // indirect
github.com/go-openapi/jsonpointer v1.0.1 // indirect
github.com/go-openapi/jsonreference v1.0.2 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
github.com/go-openapi/swag/loading v0.29.2 // indirect
github.com/go-openapi/swag/pools v0.29.2 // indirect
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.4 // indirect
github.com/gofrs/flock v0.13.1 // indirect
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-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.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-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
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.11.0 // indirect
github.com/jjti/go-spancheck v0.6.5 // 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.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.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
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.8.0 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.6.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.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.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.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
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.3.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.29.0 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.9.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/subosito/gotenv v1.6.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.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.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
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.10.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // 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.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.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.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
)
tool (
github.com/air-verse/air
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
github.com/swaggo/swag/cmd/swag
)
// air (via Hugo) and golangci-lint need different github.com/gobwas/glob APIs; pin Hugo 0.164 + glob 0.2.3.
exclude (
github.com/alecthomas/go-check-sumtype v0.5.0
github.com/gobwas/glob v1.0.0
github.com/gohugoio/hugo v0.166.0
)
+788
View File
@@ -0,0 +1,788 @@
4d63.com/gocheckcompilerdirectives v1.4.0 h1:ZLq62rbGWVmQhiZ8kuNVIT/M09xCSTdJz9K3xOdT/CY=
4d63.com/gocheckcompilerdirectives v1.4.0/go.mod h1:9ZOAiMOjqC/nRwci2fcUXVHUNLG/cH6r6rhUh+jTFtQ=
4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU=
4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
codeberg.org/chavacava/garif v0.2.1 h1:K9oYxSlvlXrHXyW26Z4q61bTpJDo1wbvXcYKar/F/LM=
codeberg.org/chavacava/garif v0.2.1/go.mod h1:oHnDSmc0f9K1MeE+MQD/yjkiIB5Xsn5y3S9Dg96Xk84=
codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI=
codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y=
dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI=
dev.gaijin.team/go/exhaustruct/v5 v5.2.0 h1:HZB49Fshn8qHJCi9VzYtrcnR1jbLteRHXfnQz8Adams=
dev.gaijin.team/go/exhaustruct/v5 v5.2.0/go.mod h1:5IshpqhgGdKIrF8lBmi3M6YKmI7T3hMzAL9nrflko88=
dev.gaijin.team/go/golib v0.8.1 h1:JYju4x9BSo+QD/AYeHULVDcvEhiFg8wOi6pT0IaZF5E=
dev.gaijin.team/go/golib v0.8.1/go.mod h1:c5fu7t1RSGMxSQgcUYO1sODbzsYnOCXJLmHeNG1Eb+0=
github.com/4meepo/tagalign v1.4.4 h1:052JDGj2pBORc6NkD30dDXpsUpoYszDeUwr9/5YZuVA=
github.com/4meepo/tagalign v1.4.4/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
github.com/Abirdcfly/dupword v0.1.8 h1:SrhcUuGsROBuChFxHALRYzyyPODWn9zwghmzPvD9Cd8=
github.com/Abirdcfly/dupword v0.1.8/go.mod h1:XZrhVnI7YGpsTiWZANSQaBJ4QpL/Tq5vIEdKJJAs9WI=
github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo=
github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY=
github.com/AlwxSin/noinlineerr v1.0.6 h1:KAvuxunTe9QxvqrFB7nZTdb/7Wzas4AvifslTnG0Ld8=
github.com/AlwxSin/noinlineerr v1.0.6/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc=
github.com/Antonboom/errname v1.1.2 h1:dxwONZJua3VB8Xh/VaCjqAcqF645sWWv7xj26zy7tdQ=
github.com/Antonboom/errname v1.1.2/go.mod h1:YeZIpgLMxT+SNkruGgYkLhzq/9vs3fsolTZegKaKDZI=
github.com/Antonboom/nilnil v1.1.2 h1:aNlFuJhaEseXe4fHO3xbjXlSeEiQVYa2lEkWD2s2hAY=
github.com/Antonboom/nilnil v1.1.2/go.mod h1:0ynwvphOLmAuMwTNDyBnDZmSwZoDpcFXmUHmzoHH2WA=
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/ClickHouse/clickhouse-go-linter v1.2.1 h1:zGEKIyd5YL08ieWG/LOUmlau2DxbxPVOfAeo+4Jz3ck=
github.com/ClickHouse/clickhouse-go-linter v1.2.1/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4=
github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g=
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/air-verse/air v1.67.4 h1:pkWBQues0094VMgUZ9MboNfS1vyCBEXeU6QxuD6/PwU=
github.com/air-verse/air v1.67.4/go.mod h1:XumHRkXbIg8CU5FqhQHgQC4oxQW/OQLwUMQAWln8cZs=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/go-check-sumtype v0.4.0 h1:gd4mE4QsA62sXlnFIs8GUv8/0xH3DOUqTkYQR1l2v28=
github.com/alecthomas/go-check-sumtype v0.4.0/go.mod h1:aYYllI8IXNjo7ZYUzI2+wyLRECMg3zo+8CZj9JTBAWU=
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 h1:dXPJ+uOJLWb8rjlAuLX7JPiRJtZKw/53Ol+WxdDJ1Eo=
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606/go.mod h1:ZH31IJyNpxpStxP2ayCcPQz+3T2G/OnxIQqe4LWvSDw=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M=
github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
github.com/andybalholm/brotli v1.2.4 h1:649LN6qF+/7JJYNN3Wdu4Dt5PS6eC/yP/Go3Bb0/AHI=
github.com/andybalholm/brotli v1.2.4/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI=
github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM=
github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM=
github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc=
github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo=
github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
github.com/bep/godartsass/v2 v2.5.0/go.mod h1:rjsi1YSXAl/UbsGL85RLDEjRKdIKUlMQHr6ChUNYOFU=
github.com/bep/golibsass v1.2.0 h1:nyZUkKP/0psr8nT6GR2cnmt99xS93Ji82ZD9AgOK6VI=
github.com/bep/golibsass v1.2.0/go.mod h1:DL87K8Un/+pWUS75ggYv41bliGiolxzDKWJAq3eJ1MA=
github.com/bep/golocales v0.2.0 h1:4H1H5UPw3ainpj5zykeEfiMRQngyaIC/t+I4Dvn+fvE=
github.com/bep/golocales v0.2.0/go.mod h1:Hl78nje8mNL3LzLeJvYN9NsIZgyFJGrGfvgO9r1+mwE=
github.com/bep/goportabletext v0.2.0 h1:CZ9f8jADBWqHwBymQiJJPCTSV/tHSA+PYzlUf86Yze0=
github.com/bep/goportabletext v0.2.0/go.mod h1:xDeA5+qcgKzJq6Q6XjAiBKtxLD3Yn7f6XP4joD3J3qU=
github.com/bep/helpers v0.12.0 h1:tD6V2DQW0B+FUynF2etR/106S/TO9akm+vA/Hk24GxY=
github.com/bep/helpers v0.12.0/go.mod h1:PfE7MGdA8sSQ19nyDh4tYbs5rAlStlJaDI21f/fnNps=
github.com/bep/imagemeta v1.0.0 h1:8JZmahegn5p1M9CpFmga7LoSxErpu1uFfpSzMUNju7A=
github.com/bep/imagemeta v1.0.0/go.mod h1:+Hlp195TfZpzsqCxtDKTG6eWdyz2+F2V/oCYfr3CZKA=
github.com/bep/lazycache v0.8.1 h1:ko6ASLjkPxyV5DMWoNNZ8B2M0weyjqXX8IZkjBoBtvg=
github.com/bep/lazycache v0.8.1/go.mod h1:pbEiFsZoq7cLXvrTll0AHOPEurB1aGGxx4jKjOtlx9w=
github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ=
github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0=
github.com/bep/overlayfs v0.11.0 h1:aymHDGC0CHpvn0XvTfgpK6skCp16oMi+tdUF32l6pPs=
github.com/bep/overlayfs v0.11.0/go.mod h1:L+ggdoKm+Y7Xb4a1osd+/LOPG4qsY62snqRqJH5Mspc=
github.com/bep/textandbinarywriter v0.1.0 h1:KXmXsRN2Uhwhm1G3e/snM8+5SPQBJrCEpIosdIBR3po=
github.com/bep/textandbinarywriter v0.1.0/go.mod h1:dAcHveajlWWU7PXhp6Dn4PIAYDg2H13Huif9xMS2w8w=
github.com/bep/tmc v0.6.0 h1:5zWy4L+3gS+Kk8czzLC4g7ETaC3wkX9ZtTRdAdL8V4s=
github.com/bep/tmc v0.6.0/go.mod h1:SNHxc3o2WSNMAYqJcAO0rxFY+pbhZzMwjIHe5xaAue0=
github.com/bits-and-blooms/bitset v1.25.0 h1:0Ro0qF4abCkM6SqWPVj29sFhAbMPAZpaDD7xhJ10beM=
github.com/bits-and-blooms/bitset v1.25.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w=
github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.9.0 h1:WCrgZ7RQnZO5oEwbVTlYgBdU3wL294kR1BSWV8vTfsU=
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=
github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4=
github.com/butuzov/mirror v1.3.3 h1:v0RsWBhfFc1RQqE/f3sHpSttKDtodFn0gFmtYyD4/hA=
github.com/butuzov/mirror v1.3.3/go.mod h1:h9BzzwYnTiHO0GzgvaTqIg7VSsOUhdIv51cHFFBmX1w=
github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk=
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 h1:/hSCZO1puetbGd2JikHKN0nAcb2mFevzxJElFoduhaw=
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16/go.mod h1:JFvmQPZh3uAu7PpSZU91bcR0BrIy24HhW+0EsW1uGxE=
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 h1:ZGHc3eaN2TIhkpwRibtr8N9jLQJOY1rh6GEByRoRmp4=
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs=
github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs=
github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
github.com/daixiang0/gci v0.14.0 h1:h6AcLqmjIOBgojhtzY2CvBnA6RawPTkBHgtMvYD5YZ8=
github.com/daixiang0/gci v0.14.0/go.mod h1:w9E+SWQ4aPQ+xYPUdqitGDoXpT4mayqOfk7Szz2U6wQ=
github.com/dave/dst v0.28.0 h1:EIEuRC5XmVKuZF2OeyMahXX9ARVt6Xxr8vI0WtIY7K8=
github.com/dave/dst v0.28.0/go.mod h1:C4mfimBeSHfsgskhR/mUYSE05s2ih+PW6g9FP3c49tE=
github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo=
github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7BbdneDfw=
github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
github.com/evanw/esbuild v0.28.2 h1:A2uETn4jrQTcXaT/shwTDTYBxDjl7fV7nXmUrJxfA2w=
github.com/evanw/esbuild v0.28.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/firefart/nonamedreturns v1.0.8 h1:iB32Dl17zJl1zlVEj/WlUWgx0HiRyQ85OUw1WHa4/II=
github.com/firefart/nonamedreturns v1.0.8/go.mod h1:vxFNvm5AfP/8rgAKFzYmnqx0yp1HjrYsErZ9pHPTznA=
github.com/frankban/quicktest v1.7.2/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/getkin/kin-openapi v0.146.0 h1:RA/1RdxrSJW4oc1+6IfnYB6AO9CaGy8GTKPh0k4Ordo=
github.com/getkin/kin-openapi v0.146.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/ghostiam/protogetter v1.0.1 h1:q4LLTx2KJHJpxrfO3BniUDWoFkoqWD/UlHhClBlUlLM=
github.com/ghostiam/protogetter v1.0.1/go.mod h1:50Fh5yZfs6SFGrd1FlmT04NcguyEOxyjNK5zGD6/EvM=
github.com/go-critic/go-critic v0.15.0 h1:MDUZIQKytIl+vPEc0Igw51m4uLTdi1ZGz2jatEqNs1g=
github.com/go-critic/go-critic v0.15.0/go.mod h1:3BPrw8nHATGm3o2n37cssOmJzpySGKCdY8JWkCzxgKQ=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg=
github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY=
github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc=
github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs=
github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk=
github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0=
github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q=
github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k=
github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM=
github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY=
github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI=
github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c=
github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc=
github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk=
github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI=
github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I=
github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE=
github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM=
github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8=
github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8=
github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU=
github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s=
github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw=
github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4=
github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ=
github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw=
github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY=
github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco=
github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4=
github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA=
github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA=
github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk=
github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus=
github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8=
github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw=
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godoc-lint/godoc-lint v0.11.4 h1:N9zUYVJ/OkznBbrBsU/zODvua64EUplMBLQWStO3+1M=
github.com/godoc-lint/godoc-lint v0.11.4/go.mod h1:7usTXoxS0QYIi5usTSCPwR0w/6xLjcz450shXjLn3t4=
github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo=
github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE=
github.com/gohugoio/gift v0.2.0 h1:vA31pP0rTVmBxBrhpY3WEt+4zM4g+1sDqYeemwsYeqc=
github.com/gohugoio/gift v0.2.0/go.mod h1:1Mrm5CjF33KpD749Dwj+UAjWZ3LC6cBXGuTMa5XwoP4=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 h1:pxlAea9eRwuAnt/zKbGqlFO2ZszpIe24YpOVLf+N+4I=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6/go.mod h1:m5hu1im5Qc7LDycVLvee6MPobJiRLBYHklypFJR0/aE=
github.com/gohugoio/go-radix v1.2.0 h1:D5GTk8jIoeXirBSc2P4E4NdHKDrenk9k9N0ctU5Yrhg=
github.com/gohugoio/go-radix v1.2.0/go.mod h1:k6vDa0ebpbpgtzSj9lPGJcA4AZwJ9xUNObUy2vczPFM=
github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4=
github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8=
github.com/gohugoio/httpcache v0.8.0 h1:hNdsmGSELztetYCsPVgjA960zSa4dfEqqF/SficorCU=
github.com/gohugoio/httpcache v0.8.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
github.com/gohugoio/hugo v0.165.0 h1:E9uRFnDw3E+Nv69OU8vsD1YLI3+tRcSHhs82y4fBXdc=
github.com/gohugoio/hugo v0.165.0/go.mod h1:mOASrT3UGLvAXPdRuY6zUH9SoKBy7VoMQIzMFf4CMi8=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0 h1:I/n6v7VImJ3aISLnn73JAHXyjcQsMVvbguQPTk9Ehus=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.7.0/go.mod h1:9LJNfKWFmhEJ7HW0in5znezMwH+FYMBIhNZ3VWtRcRs=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0 h1:p13Q0DBCrBRpJGtbtlgkYNCs4TnIlZJh8vHgnAiofrI=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.5.0/go.mod h1:ob9PCHy/ocsQhTz68uxhyInaYCbbVNpOOrJkIoSeD+8=
github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
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=
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU7GpP5BTtQTMWtVtj+mfPijgZeIg=
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA=
github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80qkk8G2B4TXU=
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=
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
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=
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs=
github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=
github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=
github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM=
github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8=
github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc=
github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk=
github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY=
github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU=
github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA=
github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M=
github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8=
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
github.com/jgautheron/goconst v1.11.0 h1:KgN90z5qXt5f0Uzf3cWXev3hfMMFUyNeKdpkSBRvLDk=
github.com/jgautheron/goconst v1.11.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc=
github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
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=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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=
github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
github.com/ldez/gomoddirectives v0.10.0 h1:Zb23rfMMkG21B6pZ9OfESzemByP5Qr7YvWcXQZE/S3Q=
github.com/ldez/gomoddirectives v0.10.0/go.mod h1:W9uh0721Vc7aaVoiMXTpKIveLmtHC9uaOSiHM4OK5a8=
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk=
github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY=
github.com/ldez/tagliatelle v0.8.0 h1:QczqVPa22JdT40p3m5JTTVmPMv5wDORqdomJn6/Pnn0=
github.com/ldez/tagliatelle v0.8.0/go.mod h1:K9n83Wv0wyVq3JYF9WI28BX8xXhDBVP3thHJ0ENuiEs=
github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ=
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE=
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww=
github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM=
github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0=
github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g=
github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8=
github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ=
github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs=
github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc=
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs=
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=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/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=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
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=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA=
github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY=
github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo=
github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng=
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU=
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs=
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ=
github.com/raeperd/recvcheck v0.3.1 h1:p7nZMxiCWkdUtY2i3o+pv1h4fJCR5gpvqAXr9326hEY=
github.com/raeperd/recvcheck v0.3.1/go.mod h1:CbE7iRocQ4uc71JwpW/CPSh4BShnkre4A9Gb508RypU=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
github.com/ryancurrah/gomodguard/v2 v2.1.3 h1:E7sz3PJwE9Ba1reVxSpF6XLCPJZ74Kfw/LabTNM4GIA=
github.com/ryancurrah/gomodguard/v2 v2.1.3/go.mod h1:CQicdLGatWMxLX53JzoBjYlsNZhHbmLv2AVa0s2aivU=
github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg=
github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0=
github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw=
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.29.0 h1:pF2HSLcnY5voqpxQumEe0O5YAAbvF4Syu4qeIewpLyc=
github.com/securego/gosec/v2 v2.29.0/go.mod h1:47C7ej+L2bC1NOxteU2aCGnCi4cZaoKzRt5y9plIvuY=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sourcegraph/go-diff v0.9.0 h1:7xcVsayWHhvxZGrqt1o89vvBApCa4nyCgO7tNxDHJ+Q=
github.com/sourcegraph/go-diff v0.9.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g=
github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tdewolff/minify/v2 v2.24.16 h1:1d+IHElvmYM2zJtuykMK/esiqc4H/7pqPKAa7jpSUUU=
github.com/tdewolff/minify/v2 v2.24.16/go.mod h1:G6yep6HFo3u2eoOcIVBKSNwpoW2wXvPjvnFRj3w1zlc=
github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8feI=
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c h1:d/FUP8GD4QiY/rdFzZmBu3KqL9eBNVzMu7W/5pGkaMM=
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntWWRfsiQ4gdunLx5WOYQzFdCsxqOrzp8=
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=
github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA=
github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g=
github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
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=
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4=
github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw=
github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4=
go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I=
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
go.augendre.info/fatcontext v0.10.1 h1:HCS/+eob32cM0mEtmZM6NiXdqvS5KiZmsR+fCubTpbc=
go.augendre.info/fatcontext v0.10.1/go.mod h1:pqpGvA9GlrXy+aXkp8L2dKz12Zp4g2FhzcAtwToU+2w=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba h1:ZoZmFkcuQ289xJilBm9Ck3W+/okrhXc4LUpbtKFQE4k=
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba/go.mod h1:qkMnvgP8C7+xkci04txL2SJFc/0PGqnkf6gs5JUC43E=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
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=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
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=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.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=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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/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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
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/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.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
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=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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=
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8/go.mod h1:MrS/+zJ1M2xvGXhKktiHbNQeQPyg3Qel+KkzT+f7/i4=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+14 -41
View File
@@ -4,27 +4,23 @@ import (
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
// validateAccessRules migrates old-style access rules (without trailing slashes) to new format
func validateAccessRules() {
if store.Access == nil {
return
}
// Get all sources
for sourcePath := range settings.Config.Server.SourceMap {
// Get all rules for this source
rules, err := store.Access.GetAllRules(sourcePath)
rules, err := state.GetAllRules(sourcePath)
if err != nil {
logger.Errorf("Failed to get access rules for source %s: %v", sourcePath, err)
continue
}
// Check if there are any rules that need migration
needsMigration := false
for oldPath := range rules {
if oldPath != "/" && !strings.HasSuffix(oldPath, "/") {
@@ -39,78 +35,55 @@ func validateAccessRules() {
migratedCount := 0
for oldPath, rule := range rules {
// Check if this path needs migration (doesn't have trailing slash and isn't root)
if oldPath != "/" && !strings.HasSuffix(oldPath, "/") {
// Create the new path with trailing slash
newPath := oldPath + "/"
// Migrate the rule to the new path
if err := migrateAccessRule(sourcePath, oldPath, newPath, rule); err != nil {
if err := migrateAccessRule(sourcePath, newPath, rule); err != nil {
logger.Errorf("Failed to migrate rule from %s to %s: %v", oldPath, newPath, err)
continue
}
// Remove the old rule
if err := removeOldAccessRule(sourcePath, oldPath); err != nil {
logger.Errorf("Failed to remove old rule %s: %v", oldPath, err)
continue
}
state.RemoveRuleByPathKey(sourcePath, oldPath)
migratedCount++
}
}
// After migration, clear cache
if migratedCount > 0 {
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
}
}
}
// migrateAccessRule creates a new access rule with the new path format
func migrateAccessRule(sourcePath, oldPath, newPath string, rule access.FrontendAccessRule) error {
// Add deny users
func migrateAccessRule(sourcePath, newPath string, rule access.FrontendAccessRule) error {
for _, user := range rule.Deny.Users {
if err := store.Access.DenyUser(sourcePath, newPath, user); err != nil && err != errors.ErrExist {
if err := state.DenyUser(sourcePath, utils.IndexPathFromNormalized(newPath, true), user); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add deny user %s: %w", user, err)
}
}
// Add deny groups
for _, group := range rule.Deny.Groups {
if err := store.Access.DenyGroup(sourcePath, newPath, group); err != nil && err != errors.ErrExist {
if err := state.DenyGroup(sourcePath, utils.IndexPathFromNormalized(newPath, true), group); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add deny group %s: %w", group, err)
}
}
// Add allow users
for _, user := range rule.Allow.Users {
if err := store.Access.AllowUser(sourcePath, newPath, user); err != nil && err != errors.ErrExist {
if err := state.AllowUser(sourcePath, utils.IndexPathFromNormalized(newPath, true), user); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add allow user %s: %w", user, err)
}
}
// Add allow groups
for _, group := range rule.Allow.Groups {
if err := store.Access.AllowGroup(sourcePath, newPath, group); err != nil && err != errors.ErrExist {
if err := state.AllowGroup(sourcePath, utils.IndexPathFromNormalized(newPath, true), group); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add allow group %s: %w", group, err)
}
}
// Add deny all rule if needed
if rule.DenyAll {
if err := store.Access.DenyAll(sourcePath, newPath); err != nil && err != errors.ErrExist {
if err := state.DenyAll(sourcePath, utils.IndexPathFromNormalized(newPath, true)); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add deny all rule: %w", err)
}
}
return nil
}
// removeOldAccessRule removes the old access rule by directly accessing the internal storage
func removeOldAccessRule(sourcePath, oldPath string) error {
// Access the internal storage directly to remove the old rule
// We need to use the unnormalized path since that's how it was stored originally
store.Access.RemoveRuleByPath(sourcePath, oldPath)
return nil
}
+224
View File
@@ -0,0 +1,224 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
func TestAdminHasSharePermissionAfterPlaywrightStartup(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
sharingConfig := "../../_docker/src/sharing/backend/config.yaml"
settings.Initialize(sharingConfig)
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
_, err := state.Initialize(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
t.Cleanup(func() { _ = state.Close() })
validateUserInfo(true)
admin, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if !admin.Permissions.Share {
t.Fatalf("admin Share=false after validateUserInfo; perms=%+v", admin.Permissions)
}
if !admin.Permissions.Admin {
t.Fatalf("admin Admin=false; perms=%+v", admin.Permissions)
}
_ = state.Close()
_, err = state.Initialize(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
admin, err = state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if !admin.Permissions.Share {
t.Fatalf("admin Share=false after DB reload; perms=%+v", admin.Permissions)
}
}
func TestAdminHasSharePermissionAfterSettingsMigration(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
alignSettingsSourcesForMigrationFixture(t)
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
_, err := state.Initialize(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
t.Cleanup(func() { _ = state.Close() })
validateUserInfo(false)
admin, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if !admin.Permissions.Share {
t.Fatalf("admin Share=false after settings migration; perms=%+v", admin.Permissions)
}
assertSourceSidebarLinkCount(t, &admin, 3)
}
func TestGrahamGainsAccessAfterSettingsMigrationStartup_dockerSourcePaths(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
configPath := filepath.Join(t.TempDir(), "config.yaml")
configYAML := `server:
sources:
- path: "/app/frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
- path: "/app/backend"
name: "docker"
config:
defaultEnabled: true
- path: "/tests/playwright-files"
name: "access"
config:
defaultEnabled: true
denyByDefault: true
auth:
methods:
password:
enabled: true
`
if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil {
t.Fatal(err)
}
settings.Initialize(configPath)
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
_, err := state.Initialize(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
t.Cleanup(func() { _ = state.Close() })
validateUserInfo(false)
graham, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
foundScope = true
break
}
}
if !foundScope {
t.Fatalf("graham should gain access scope after validateUserInfo: %#v", graham.BackendScopes)
}
foundLink := false
for _, link := range graham.SidebarLinks {
if link.Name == "access" || link.SourceName == fixtureAccessSource {
foundLink = true
break
}
}
if !foundLink {
t.Fatalf("graham should have access sidebar link after validateUserInfo: %#v", graham.SidebarLinks)
}
}
func TestGrahamGainsAccessAfterSettingsMigrationStartup(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
alignSettingsSourcesForMigrationFixture(t)
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
_, err := state.Initialize(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
t.Cleanup(func() { _ = state.Close() })
validateUserInfo(false)
graham, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
foundScope = true
break
}
}
if !foundScope {
t.Fatalf("graham should gain access scope after validateUserInfo: %#v", graham.BackendScopes)
}
foundLink := false
for _, link := range graham.SidebarLinks {
if link.Name == "access" || link.SourceName == fixtureAccessSource {
foundLink = true
break
}
}
if !foundLink {
t.Fatalf("graham should have access sidebar link after validateUserInfo: %#v", graham.SidebarLinks)
}
if len(graham.SidebarLinks) == 0 {
t.Fatal("graham should have sidebar links after migration so frontend does not fall back to all sources")
}
}
func assertSourceSidebarLinkCount(t *testing.T, user *users.User, want int) {
t.Helper()
count := 0
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
count++
}
}
if count != want {
t.Fatalf("user %q has %d source sidebar links, want %d: %v",
user.Username, count, want, user.SidebarLinks)
}
}
+136 -287
View File
@@ -2,19 +2,18 @@ package cmd
import (
"bufio"
"flag"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -22,157 +21,44 @@ var (
configPath string
)
// return bool to indicate if the program should continue running
func runCLI() bool {
generateYaml()
// Global flags
var help bool
// Override the default usage output to use generalUsage()
flag.Usage = generalUsage
flag.StringVar(&configPath, "c", "", "Path to the config file, default: config.yaml")
flag.BoolVar(&help, "h", false, "Get help about commands")
if configPath == "" {
configPath = os.Getenv("FILEBROWSER_CONFIG")
// backwards compatibility in docker, prefer config.yaml if it exists
if configPath != "" {
_, err := os.Stat(configPath)
if err != nil {
logger.Fatalf("config file %v does not exist, please create it or set the FILEBROWSER_CONFIG environment variable to a valid config file path", configPath)
}
} else {
configPath = "config.yaml"
}
}
// Parse global flags (before subcommands)
flag.Parse() // print generalUsage on error
// Show help if requested
if help {
generalUsage()
return false
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, dbConfig string
var asAdmin bool
var fsPath, indexPath, ruleCategory, value string
var allow bool
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
setCmd.BoolVar(&asAdmin, "a", false, "Create a user as admin user, used in combination with -u")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
setCmd.StringVar(&fsPath, "f", "", "Real filesystem path (e.g. /mnt/storage) for rule command")
setCmd.StringVar(&indexPath, "p", "", "Index path (e.g. /secret) for rule command")
setCmd.StringVar(&ruleCategory, "r", "", "Rule category: 'user', 'group', or 'all' (for deny only)")
setCmd.StringVar(&value, "v", "", "Value: username or groupname (not required if ruleCategory is 'all')")
setCmd.BoolVar(&allow, "allow", false, "Allow access (default: false, which means deny)")
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "setup":
createConfig(configPath)
return false
case "set":
if len(os.Args) < 3 {
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
os.Exit(1)
}
subcommand := os.Args[2]
// Handle old syntax: "set -u user,pass -c config.yaml"
if subcommand == "-u" {
// Parse flags starting from os.Args[2:] to include the -u flag
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
err = setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
}
// New pattern: subcommand-based (e.g., "set user" or "set rule")
// Parse flags starting from os.Args[3:] to skip the subcommand
err := setCmd.Parse(os.Args[3:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
switch subcommand {
case "rule":
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
default:
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", subcommand)
os.Exit(1)
}
case "version":
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return false
}
}
return true
}
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
// Validate required parameters
if fsPath == "" {
return fmt.Errorf("real filesystem path is required: use -f <fsPath>")
func setRule(backendSourcePath, indexPath, ruleCategory, value string, allow bool) error {
if backendSourcePath == "" {
return fmt.Errorf("backend source path is missing; check --source / -s")
}
if indexPath == "" {
return fmt.Errorf("index path is required: use -p <indexPath>")
return fmt.Errorf("--path / -p is required (index path within the source, e.g. /)")
}
if ruleCategory == "" {
return fmt.Errorf("ruleCategory is required: use -r <user|group|all>")
return fmt.Errorf("role is required: use --role / -r <user|group|all>")
}
if ruleCategory != "all" && value == "" {
return fmt.Errorf("value is required when ruleCategory is 'user' or 'group': use -v <username|groupname>")
return fmt.Errorf("value is required when role is 'user' or 'group': use --value / -v <username|groupname>")
}
// Initialize store and settings
_ = getStore(dbConfig) // ignore bool check
parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true)
if err != nil {
return err
}
// Apply the rule based on allow flag and ruleCategory
var err error
if allow {
switch ruleCategory {
case "user":
err = store.Access.AllowUser(fsPath, indexPath, value)
err = state.AllowUser(backendSourcePath, parsedPath, value)
case "group":
err = store.Access.AllowGroup(fsPath, indexPath, value)
err = state.AllowGroup(backendSourcePath, parsedPath, value)
default:
return fmt.Errorf("invalid ruleCategory for allow: must be 'user' or 'group'")
return fmt.Errorf("invalid role for allow: must be 'user' or 'group'")
}
} else {
switch ruleCategory {
case "user":
err = store.Access.DenyUser(fsPath, indexPath, value)
err = state.DenyUser(backendSourcePath, parsedPath, value)
case "group":
err = store.Access.DenyGroup(fsPath, indexPath, value)
err = state.DenyGroup(backendSourcePath, parsedPath, value)
case "all":
err = store.Access.DenyAll(fsPath, indexPath)
err = state.DenyAll(backendSourcePath, parsedPath)
default:
return fmt.Errorf("invalid ruleCategory: must be 'user', 'group', or 'all'")
return fmt.Errorf("invalid role: must be 'user', 'group', or 'all'")
}
}
@@ -185,72 +71,61 @@ func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool
action = "allow"
}
if ruleCategory == "all" {
fmt.Printf("successfully added %s rule for all users on index path '%s' in filesystem path '%s'\n", action, indexPath, fsPath)
fmt.Printf("successfully added %s rule for all users on index path '%s' in filesystem path '%s'\n", action, indexPath, backendSourcePath)
} else {
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, fsPath)
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, backendSourcePath)
}
return nil
}
func setUser(dbConfig string, asAdmin bool) error {
if len(os.Args) < 4 {
return fmt.Errorf("missing username and password for 'set user'. Use 'set -u username,password'")
}
userInfo := strings.Split(os.Args[3], ",")
if len(userInfo) < 2 {
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", userInfo)
}
username := userInfo[0]
password := userInfo[1]
_ = getStore(dbConfig) // ignore bool check
user, err := store.Users.Get(username)
func setUser(username, password string, asAdmin bool) error {
user, err := state.GetUserByUsername(username)
if err != nil {
newUser := users.User{
Username: username,
LoginMethod: users.LoginMethodPassword,
NonAdminEditable: users.NonAdminEditable{
Password: password,
FrontendUser: users.FrontendUser{
Username: username,
LoginMethod: users.LoginMethodPassword,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
newUser.BackendScopes = append(newUser.BackendScopes, users.BackendScope{
Path: source.Path,
Scope: source.Config.DefaultUserScope,
})
}
}
// Create the user logic
if asAdmin {
logger.Infof("Creating user as admin: %s\n", username)
} else {
logger.Infof("Creating non-admin user: %s\n", username)
}
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Account.Permissions)
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser, newUser.Permissions)
err = state.CreateUser(&newUser, password)
if err != nil {
return fmt.Errorf("could not create user: %v", err)
}
if dirErr := files.MakeUserDirs(&newUser, true); dirErr != nil {
logger.Error(dirErr.Error())
}
fmt.Printf("successfully created user")
return nil
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
logger.Infof("Converting user %s from %s to password login method", username, user.LoginMethod)
}
user.Password = password
user.TOTPSecret = "" // reset TOTP secret if it exists
user.TOTPNonce = "" // reset TOTP nonce if it exists
user.TOTPSecret = ""
user.TOTPNonce = ""
user.LoginMethod = users.LoginMethodPassword
if asAdmin {
user.Permissions.Admin = true
}
// Ensure version is set for existing users being updated
if user.Version == 0 {
user.Version = 1
user.Version = users.CurrentUserMigrationVersion
}
err = store.Users.Save(user, true, false)
err = state.UpdateUser(&user, password, "password", "totpSecret", "totpNonce", "loginMethod", "permissions", "version")
if err != nil {
return fmt.Errorf("could not update user: %v", err)
}
@@ -258,53 +133,30 @@ func setUser(dbConfig string, asAdmin bool) error {
return nil
}
// UserDefaults defines default settings for new users.
type UserDefaults struct {
Permissions users.Permissions `yaml:"permissions"`
func promoteUser(username string) error {
user, err := state.GetUserByUsername(username)
if err != nil {
return fmt.Errorf("user %s not found", username)
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s cannot be promoted: only password-login users can be granted admin via CLI", username)
}
if user.Permissions.Admin {
fmt.Printf("user %s is already an admin\n", username)
return nil
}
user.Permissions.Admin = true
if user.Version == 0 {
user.Version = users.CurrentUserMigrationVersion
}
err = state.UpdateUser(&user, "", "permissions")
if err != nil {
return fmt.Errorf("could not promote user: %v", err)
}
fmt.Printf("successfully promoted user to admin: %s\n", username)
return nil
}
// Frontend defines settings related to the web interface.
type Frontend struct {
Name string `yaml:"name,omitempty"`
}
// Source defines a directory to be served.
type Source struct {
Name string `yaml:"name,omitempty"`
Path string `yaml:"path"`
}
// Server defines server-specific configurations.
type Server struct {
Port int `yaml:"port"`
Database string `yaml:"database"`
Sources []Source `yaml:"sources"`
Logging []LogConfig `json:"logging"`
}
type LogConfig struct {
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Utc bool `json:"utc"` // use UTC time in the output instead of local time
}
type Auth struct {
AdminUsername string `yaml:"adminUsername"`
AdminPassword string `yaml:"adminPassword"`
}
// Settings is the top-level configuration structure.
type Settings struct {
Server Server `yaml:"server"`
Frontend Frontend `yaml:"frontend,omitempty"`
Auth Auth `yaml:"auth"`
UserDefaults UserDefaults `yaml:"userDefaults"`
}
// askQuestion displays a prompt and reads a line of input from the user.
// It returns the defaultValue if the user's input is empty.
func askQuestion(reader *bufio.Reader, prompt string, defaultValue string) string {
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
input, _ := reader.ReadString('\n')
@@ -315,9 +167,6 @@ func askQuestion(reader *bufio.Reader, prompt string, defaultValue string) strin
return input
}
// askYesNoQuestion prompts the user with a yes/no question and returns a boolean.
// It retries until valid input ("yes", "y", "no", "n") is received.
// The defaultValue must be "yes" or "no".
func askYesNoQuestion(reader *bufio.Reader, prompt string, defaultValue string) bool {
for {
answer := askQuestion(reader, prompt, defaultValue)
@@ -332,131 +181,132 @@ func askYesNoQuestion(reader *bufio.Reader, prompt string, defaultValue string)
}
}
// createConfig orchestrates the configuration process by asking the user a series of questions.
func createConfig(configpath string) {
// check if config file exists
if _, err := os.Stat("config.yaml"); err == nil {
fmt.Println("Config file 'config.yaml' already exists, skipping setup.")
return
func createConfig(configpath string, noInput bool) error {
if noInput {
return fmt.Errorf("'setup' requires interactive input; rerun without --no-input")
}
reader := bufio.NewReader(os.Stdin)
config := Settings{
Server: Server{
Logging: []LogConfig{
{
Levels: "info|warning|error",
ApiLevels: "info|warning|error",
Output: "stdout",
NoColors: false,
Utc: false,
},
},
Sources: []Source{
{
Path: "",
},
},
},
config := settings.SetDefaults(false)
config.Server.Sources = []*settings.Source{{Path: "./"}}
if configpath == "" {
configpath = "config.yaml"
}
fmt.Println("--- Starting Configuration Setup ---")
realPath := ""
// 1. Ask for the source filesystem path (with validation)
for {
config.Server.Sources[0].Path = askQuestion(reader, "What is the source filesystem path?", "./")
// Convert relative path to absolute path
absolutePath, err := filepath.Abs(config.Server.Sources[0].Path)
if err == nil {
var isDir bool
// Resolve symlinks and get the real path
realPath, isDir, _ = iteminfo.ResolveSymlinks(absolutePath)
if realPath != "" && isDir {
break // Valid path found, exit loop
break
}
}
fmt.Printf("Error: The path '%s' does not exist or isn't valid. Please try again.\n", config.Server.Sources[0].Path)
}
// 2. Ask for the source name
defaultSourceName := filepath.Base(realPath)
sourceName := askQuestion(reader, "What should the first source name be?", defaultSourceName)
if sourceName != defaultSourceName {
config.Server.Sources[0].Name = sourceName
}
// 3. Ask for server port (with validation)
for {
portStr := askQuestion(reader, "What port should the server listen on?", "80")
port, err := strconv.Atoi(portStr)
if err == nil && (port >= 1 && port <= 65535) {
config.Server.Port = port
break // Port is valid, exit loop
config.Http.Port = port
break
}
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
}
config.Server.Logging = []settings.LogConfig{
{
Output: "stdout",
Levels: "info|warning|error",
},
}
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 {
// "database.db" is the reserved legacy-bolt filename and is rejected
// at startup (see backend/cmd/migrate.go), so the wizard must not
// suggest it as the default.
config.Server.DatabaseV2.Path = askQuestion(reader, "What should the file name and path be for the database?", "./filebrowser.sqlite")
if strings.HasSuffix(config.Server.DatabaseV2.Path, ".sqlite") {
break
}
fmt.Printf("Error: invalid options given '%s'. valid options: 'info|warning|error|debug'.\n", invalidOptions)
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
}
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
}
for {
config.Server.Database = askQuestion(reader, "What should the file name and path be for the database?", "./database.db")
if strings.HasSuffix(config.Server.Database, ".db") {
break // Valid path found, exit loop
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .db", config.Server.Database)
}
// 4. Ask for the application brand name
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
// 5. Ask for admin username and password
config.Auth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
config.Auth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
// 6. Ask boolean (Yes/No) questions using the helper
config.UserDefaults.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.UserDefaults.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
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{
View: true,
Download: true,
Modify: modifyDefault,
Create: modifyDefault,
Delete: modifyDefault,
})
config.UserDefaults.Account.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
fmt.Println("--- Configuration Complete ---")
// marshall yaml and write to file
// Marshal the struct to YAML bytes
yamlData, err := yaml.Marshal(&config)
yamlData, err := generateSetupConfigYAML(&config)
if err != nil {
return
return fmt.Errorf("generating config YAML: %w", err)
}
// Write the YAML data to the file
err = os.WriteFile("config.yaml", yamlData, 0644) // 0644 provides read/write for owner, read for others
err = os.WriteFile(configpath, []byte(yamlData), 0600)
if err != nil {
return
return fmt.Errorf("writing config file: %w", err)
}
// cleanup database if it exists
if _, err := os.Stat(config.Server.Database); err == nil {
if _, err := os.Stat(config.Server.DatabaseV2.Path); err == nil {
response := askYesNoQuestion(reader, "Database specified already exists. Move database file to backup to start fresh?", "no")
if !response {
return
return nil
}
// move database file to backup
backupPath := config.Server.Database + ".bak"
err = os.Rename(config.Server.Database, backupPath)
backupPath := config.Server.DatabaseV2.Path + ".bak"
err = os.Rename(config.Server.DatabaseV2.Path, backupPath)
if err != nil {
fmt.Printf("Error moving database file to backup: %v\n", err)
} else {
fmt.Printf("Database file moved to backup: %s\n", backupPath)
}
}
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() {
@@ -469,7 +319,6 @@ func generateYaml() {
if generateConfig {
os.Exit(0)
}
}
func SplitByMultiple(str string) []string {
+217
View File
@@ -0,0 +1,217 @@
package cmd
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/alecthomas/kong"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func freshCLI() cliRoot {
return cliRoot{}
}
func newCLIParser(t *testing.T, cli *cliRoot) *kong.Kong {
t.Helper()
parser, err := kong.New(cli, kong.Name("filebrowser"), kong.Bind(&cli.Globals))
require.NoError(t, err)
return parser
}
func TestParseDefaultServerCommand(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{})
require.NoError(t, err)
assert.Equal(t, "run", ctx.Command())
}
func TestParseVersionCommand(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"version"})
require.NoError(t, err)
assert.Equal(t, "version", ctx.Command())
}
func TestParseInitCommand(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"init"})
require.NoError(t, err)
assert.Equal(t, "init", ctx.Command())
}
func TestParseGlobalConfigFlag(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
_, err := parser.Parse([]string{"-c", "/tmp/custom.yaml", "version"})
require.NoError(t, err)
assert.Equal(t, "/tmp/custom.yaml", cli.Config)
}
func TestParseUserSetWithPasswordFlag(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"user", "set", "alice", "--password", "secret", "--admin"})
require.NoError(t, err)
assert.Equal(t, "user set <username>", ctx.Command())
assert.Equal(t, "alice", cli.User.Set.Username)
assert.True(t, cli.User.Set.Password.Provided)
assert.Equal(t, "secret", cli.User.Set.Password.Inline)
assert.True(t, cli.User.Set.Admin)
}
func TestParseUserSetConfigFlagOrder(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
_, err := parser.Parse([]string{"-c", "other.yaml", "user", "set", "bob", "--password", "x"})
require.NoError(t, err)
assert.Equal(t, "other.yaml", cli.Config)
}
func TestParseSetRuleFlags(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{
"set", "rule",
"-s", "access", "-p", "/", "-r", "user", "-v", "admin", "--allow",
})
require.NoError(t, err)
assert.Equal(t, "set rule", ctx.Command())
assert.Equal(t, "access", cli.Set.Rule.Source)
assert.Equal(t, "/", cli.Set.Rule.Path)
assert.Equal(t, "user", cli.Set.Rule.Role)
assert.Equal(t, "admin", cli.Set.Rule.Value)
assert.True(t, cli.Set.Rule.Allow)
}
func TestParseLegacySetUser(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"set", "-u", "alice,secret", "-a"})
require.NoError(t, err)
assert.Equal(t, "set", ctx.Command())
assert.Equal(t, "alice,secret", cli.Set.User)
assert.True(t, cli.Set.Admin)
}
func TestPasswordFlagResolveInline(t *testing.T) {
p := passwordFlag{Provided: true, Inline: "inline-pass"}
got, err := p.resolve(true)
require.NoError(t, err)
assert.Equal(t, "inline-pass", got)
}
func TestPasswordFlagResolveStdin(t *testing.T) {
r, w, err := os.Pipe()
require.NoError(t, err)
oldStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() { os.Stdin = oldStdin })
_, err = w.WriteString("piped-secret\n")
require.NoError(t, err)
require.NoError(t, w.Close())
p := passwordFlag{Provided: true}
got, err := p.resolve(false)
require.NoError(t, err)
assert.Equal(t, "piped-secret", got)
}
func TestPasswordFlagMissing(t *testing.T) {
p := passwordFlag{}
_, err := p.resolve(false)
require.Error(t, err)
assert.Contains(t, err.Error(), "--password is required")
}
func TestPasswordFlagNoInputRequiresInline(t *testing.T) {
p := passwordFlag{Provided: true}
_, err := p.resolve(true)
require.Error(t, err)
assert.Contains(t, err.Error(), "--no-input")
}
func TestSplitByMultiple(t *testing.T) {
assert.Equal(t, []string{"info", "warning", "error"}, SplitByMultiple("info|warning|error"))
}
func TestSetCmdRunSkipsWhenRuleSelected(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"set", "rule", "-s", "x", "-p", "/", "-r", "all"})
require.NoError(t, err)
cli.Set.User = "should-not-run"
err = cli.Set.Run(ctx)
require.NoError(t, err)
}
func TestSetCmdRunLegacyUser(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"set", "-u", "bad"})
require.NoError(t, err)
err = cli.Set.Run(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "not enough info")
}
func TestPasswordReadAllFromReader(t *testing.T) {
var buf bytes.Buffer
buf.WriteString(" trimmed \n")
data, err := io.ReadAll(&buf)
require.NoError(t, err)
assert.Equal(t, "trimmed", strings.TrimSpace(string(data)))
}
func TestResolveConfigPathEnvMissingFile(t *testing.T) {
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
t.Setenv("FILEBROWSER_CONFIG", missing)
config := ""
resolveConfigPath(&config)
assert.Equal(t, missing, config)
_, err := os.Stat(config)
assert.Error(t, err)
}
func TestSetupNoInputRejected(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"setup", "--no-input"})
require.NoError(t, err)
err = ctx.Run()
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:")
}
+101
View File
@@ -0,0 +1,101 @@
package cmd
import (
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupCLIUserTest(t *testing.T) {
t.Helper()
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.InitializeCLI("../../../_docker/src/noauth/backend/config.yaml")
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
store, _, err := state.Open(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(store)
t.Cleanup(func() { _ = state.Close() })
}
func TestSetUserConvertsOIDCToPassword(t *testing.T) {
setupCLIUserTest(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatal(err)
}
if err := setUser("graham", "newpassword", false); err != nil {
t.Fatalf("setUser: %v", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if loaded.LoginMethod != users.LoginMethodPassword {
t.Fatalf("loginMethod = %q, want password", loaded.LoginMethod)
}
}
func TestPromoteUserRejectsNonPasswordLoginMethod(t *testing.T) {
setupCLIUserTest(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatal(err)
}
err := promoteUser("graham")
if err == nil {
t.Fatal("promoteUser: expected error for oidc user")
}
if !strings.Contains(err.Error(), "password-login") {
t.Fatalf("promoteUser err = %q, want password-login restriction", err.Error())
}
}
func TestPromoteUserAllowsPasswordLoginMethod(t *testing.T) {
setupCLIUserTest(t)
passwordUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodPassword,
},
}
if err := state.CreateUser(passwordUser, "secret"); err != nil {
t.Fatal(err)
}
if err := promoteUser("graham"); err != nil {
t.Fatalf("promoteUser: %v", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if !loaded.Permissions.Admin {
t.Fatal("expected admin after promote")
}
}
+83
View File
@@ -0,0 +1,83 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
const settingsMigrationBoltMinSize = 1024
// Docker source paths stored in the committed database.db.old Bolt fixture.
const (
fixturePlaywrightSource = "/app/frontend/tests/playwright-files"
fixtureDockerSource = "/app/backend"
fixtureAccessSource = "/tests/playwright-files"
)
// settingsMigrationBoltPath returns the committed read-only BoltDB reference used by
// migration tests and the Playwright settings Docker image. Tests must never write to it.
func settingsMigrationBoltPath(t *testing.T) string {
t.Helper()
path, err := filepath.Abs(filepath.Join("..", "..", "_docker", "src", "settings", "backend", "database.db.old"))
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf(
"settings migration fixture not found at %s: %v (commit _docker/src/settings/backend/database.db.old)",
path,
err,
)
}
if info.Size() < settingsMigrationBoltMinSize {
t.Fatalf("settings migration fixture at %s is empty or too small (%d bytes)", path, info.Size())
}
return path
}
func settingsMigrationConfigPath(t *testing.T) string {
t.Helper()
path, err := filepath.Abs(filepath.Join("..", "..", "_docker", "src", "settings", "backend", "config.yaml"))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("settings migration config not found at %s: %v", path, err)
}
return path
}
// alignSettingsSourcesForMigrationFixture maps config sources to the Docker paths
// stored in database.db.old so migration tests can run locally without modifying
// the committed Bolt fixture.
func alignSettingsSourcesForMigrationFixture(t *testing.T) {
t.Helper()
byName := map[string]string{
"playwright + files": fixturePlaywrightSource,
"docker": fixtureDockerSource,
"access": fixtureAccessSource,
}
newSourceMap := make(map[string]*settings.Source, len(byName))
for name, dockerPath := range byName {
source, ok := settings.Config.Server.NameToSource[name]
if !ok {
t.Fatalf("source %q not in config", name)
}
if source.Path != dockerPath {
delete(settings.Config.Server.SourceMap, source.Path)
source.Path = dockerPath
}
newSourceMap[dockerPath] = source
settings.Config.Server.NameToSource[name] = source
}
settings.Config.Server.SourceMap = newSourceMap
sources := make([]*settings.Source, 0, len(byName))
for _, name := range []string{"playwright + files", "docker", "access"} {
sources = append(sources, settings.Config.Server.NameToSource[name])
}
settings.Config.Server.Sources = sources
}
+234
View File
@@ -0,0 +1,234 @@
package cmd
import (
"fmt"
"io"
"os"
"strings"
"github.com/alecthomas/kong"
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/term"
)
// Globals holds flags shared across all commands (order-independent).
type Globals struct {
Config string `short:"c" name:"config" default:"config.yaml" env:"FILEBROWSER_CONFIG" help:"Path to config file"`
NoInput bool `name:"no-input" help:"Disable interactive prompts (fail if input required)"`
}
type runCmd struct{}
type versionCmd struct{}
type setupCmd struct{}
type initCmd struct{}
type SetCmd struct {
User string `short:"u" help:"Deprecated: comma-separated username,password. Use 'user set' instead."`
Admin bool `short:"a" help:"Create user as admin (used with -u)"`
Rule SetRuleCmd `cmd:"" name:"rule" help:"Add or update an access rule"`
}
type SetRuleCmd struct {
Source string `short:"s" name:"source" aliases:"f,sourceName" required:"" help:"Source name from config"`
Path string `short:"p" name:"path" aliases:"sourcePath" required:"" help:"Index path within the source"`
Role string `short:"r" name:"role" enum:"user,group,all" required:"" help:"Rule target type"`
Value string `short:"v" name:"value" help:"Username or group name (required for user/group)"`
Allow bool `name:"allow" help:"Allow access (default: deny)"`
}
type UserCmd struct {
Set UserSetCmd `cmd:"" name:"set" help:"Create or update a password-authenticated user"`
Promote UserPromoteCmd `cmd:"" name:"promote" help:"Grant admin permissions without changing password"`
}
type UserPromoteCmd struct {
Username string `arg:"" help:"Username to promote"`
}
type UserSetCmd struct {
Username string `arg:"" help:"Username"`
Password passwordFlag `name:"password" help:"Password; omit value to prompt (TTY) or read stdin (pipe)"`
Admin bool `short:"a" name:"admin" help:"Grant admin permissions"`
}
// passwordFlag accepts --password VALUE (inline) or --password alone (prompt/pipe).
type passwordFlag struct {
Provided bool
Inline string
}
func (p *passwordFlag) Decode(ctx *kong.DecodeContext) error {
p.Provided = true
if ctx.Scan.Peek().IsValue() {
return ctx.Scan.PopValueInto("password", &p.Inline)
}
return nil
}
func (p passwordFlag) resolve(noInput bool) (string, error) {
if !p.Provided {
return "", fmt.Errorf("--password is required (inline value, interactive prompt, or piped stdin)")
}
if p.Inline != "" {
return p.Inline, nil
}
if noInput {
return "", fmt.Errorf("--password requires an inline value or piped stdin when --no-input is set")
}
if term.IsTerminal(int(os.Stdin.Fd())) {
return promptPassword()
}
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("read password from stdin: %w", err)
}
password := strings.TrimSpace(string(data))
if password == "" {
return "", fmt.Errorf("password must not be empty")
}
return password, nil
}
func promptPassword() (string, error) {
if _, err := fmt.Fprint(os.Stderr, "Password: "); err != nil {
return "", err
}
bytePassword, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", fmt.Errorf("read password: %w", err)
}
password := string(bytePassword)
if password == "" {
return "", fmt.Errorf("password must not be empty")
}
return password, nil
}
var rootCLI cliRoot
type cliRoot struct {
Globals `embed:""`
Run runCmd `cmd:"" default:"1" hidden:"" help:"Start the FileBrowser server"`
Version versionCmd `cmd:"" name:"version" help:"Print version information"`
Setup setupCmd `cmd:"" name:"setup" help:"Interactive configuration setup"`
Init initCmd `cmd:"" name:"init" hidden:"" help:"Initialize the database or migrate from a legacy database"`
Set SetCmd `cmd:"" name:"set" help:"Set configuration values (deprecated: use 'user set' for users)"`
User UserCmd `cmd:"" name:"user" help:"User management"`
}
func (versionCmd) Run() error {
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return nil
}
func (setupCmd) Run(globals *Globals) error {
return createConfig(globals.Config, globals.NoInput)
}
func (initCmd) Run() error {
return nil
}
func (s *SetCmd) Run(ctx *kong.Context) error {
if strings.HasPrefix(ctx.Command(), "set rule") {
return nil
}
if s.User == "" {
return fmt.Errorf("missing subcommand for 'set'. Use 'set rule' or 'user set'")
}
fmt.Fprintln(os.Stderr, "warning: 'set -u' is deprecated; use 'user set' instead")
userInfo := strings.SplitN(s.User, ",", 2)
if len(userInfo) < 2 || userInfo[0] == "" || userInfo[1] == "" {
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", strings.Split(s.User, ","))
}
return setUser(userInfo[0], userInfo[1], s.Admin)
}
func (r *SetRuleCmd) Run() error {
sourceInfo, ok := settings.Config.Server.NameToSource[r.Source]
if !ok {
return fmt.Errorf("invalid source name: %s", r.Source)
}
return setRule(sourceInfo.Path, r.Path, r.Role, r.Value, r.Allow)
}
func (u *UserSetCmd) Run(globals *Globals) error {
password, err := u.Password.resolve(globals.NoInput)
if err != nil {
return err
}
return setUser(u.Username, password, u.Admin)
}
func (p *UserPromoteCmd) Run() error {
return promoteUser(p.Username)
}
func resolveConfigPath(config *string) {
envConfig := os.Getenv("FILEBROWSER_CONFIG")
if *config == "" {
if envConfig != "" {
*config = envConfig
} else {
*config = "config.yaml"
}
}
}
func requireExistingConfig(config string) {
envConfig := os.Getenv("FILEBROWSER_CONFIG")
if envConfig != "" && config == envConfig {
if _, err := os.Stat(config); err != nil {
logger.Fatalf("config file %v does not exist, please create it or set the FILEBROWSER_CONFIG environment variable to a valid config file path", config)
}
}
}
func runCLI() (keepGoing bool, dbExists bool) {
generateYaml()
parser, err := kong.New(&rootCLI,
kong.Name("filebrowser"),
kong.Description("FileBrowser Quantum - A modern web-based file manager"),
kong.Bind(&rootCLI.Globals),
)
if err != nil {
logger.Fatalf("failed to configure CLI: %v", err)
}
ctx, err := parser.Parse(os.Args[1:])
parser.FatalIfErrorf(err)
resolveConfigPath(&rootCLI.Config)
configPath = rootCLI.Config
cmd := ctx.Command()
switch {
case cmd == "" || cmd == "run":
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath, false)
return true, dbExists
case cmd == "version" || cmd == "setup":
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, false
case cmd == "init" || cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath, true)
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, dbExists
default:
parser.Fatalf("unexpected command: %q", cmd)
return false, false
}
}
+12
View File
@@ -0,0 +1,12 @@
package cmd
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+542
View File
@@ -0,0 +1,542 @@
package cmd
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
const migrationGuideURL = "https://filebrowserquantum.com/en/docs/getting-started/v2/migration"
func migrationErrf(format string, args ...any) error {
return fmt.Errorf(format+" See the v2 migration guide: "+migrationGuideURL, args...)
}
// checkMigrationNeeded is true when server.database.migrateFrom points at a BoltDB file to import and
// server.database.path (SQLite) does not exist yet or is empty.
func checkMigrationNeeded() bool {
boltPath := settings.Config.Server.DatabaseV2.MigrateFrom
if boltPath == "" {
return false
}
if sqliteDatabasePopulated(settings.Config.Server.DatabaseV2.Path) {
return false
}
return legacyBoltDatabasePopulated(boltPath)
}
// validateDatabasePaths enforces database path rules before opening or creating SQLite:
// 1. On a fresh install, fail if server.database.path is database.db or an unrenamed
// legacy database file (database.db) is present in the working directory.
// 2. On a fresh install with server.database.migrateFrom set, fail if the legacy database file is missing
// or empty.
// 3. When SQLite already exists and server.database.migrateFrom is set, fail if the legacy database file
// is missing or empty.
//
// Otherwise the existing SQLite database is used, or a new one is created on first open.
func validateDatabasePaths() error {
sqlitePath := settings.Config.Server.DatabaseV2.Path
boltPath := settings.Config.Server.DatabaseV2.MigrateFrom
sqliteExists := sqliteDatabasePopulated(sqlitePath)
if !sqliteExists {
if filepath.Base(sqlitePath) == "database.db" {
return migrationErrf(
`server.database.path cannot be "database.db": that name is reserved for the legacy database file. ` +
`Rename database.db to database.db.old, set server.database.path to a new SQLite filename ` +
`and set server.database.migrateFrom to "database.db.old".`,
)
}
if _, err := os.Stat("database.db"); err == nil {
return migrationErrf(
`legacy database file found in the working directory. ` +
`Rename it to database.db.old, set server.database.migrateFrom to "database.db.old" ` +
`or use migrateFrom: "default".`,
)
}
if boltPath != "" {
return legacyBoltDatabaseError(boltPath, sqlitePath, true)
}
return nil
}
if boltPath != "" {
return legacyBoltDatabaseError(boltPath, sqlitePath, false)
}
return nil
}
func legacyBoltDatabaseError(boltPath, sqlitePath string, freshInstall bool) error {
stat, err := os.Stat(boltPath)
if err != nil {
if os.IsNotExist(err) {
if freshInstall {
return migrationErrf(
`server.database.migrateFrom is %q but that file does not exist, and no SQLite database exists at server.database.path (%q). `+
`Check the migrateFrom path or restore your renamed legacy database backup.`,
boltPath, sqlitePath,
)
}
return migrationErrf(
`server.database.migrateFrom is %q but that file does not exist. `+
`Migration appears complete — remove server.database.migrateFrom from your config.`,
boltPath,
)
}
return migrationErrf(
`server.database.migrateFrom is %q but cannot be read: %v. `+
`Check file permissions or restore your backed-up database file.`,
boltPath, err,
)
}
if stat.Size() == 0 {
if freshInstall {
return migrationErrf(
`server.database.migrateFrom is %q but that file is empty, and no SQLite database exists at server.database.path (%q). `+
`Restore your backed-up database file or fix the migrateFrom path.`,
boltPath, sqlitePath,
)
}
return migrationErrf(
`server.database.migrateFrom is %q but that file is empty. `+
`Remove server.database.migrateFrom from your config, or restore the legacy database backup if migration is not complete.`,
boltPath,
)
}
return nil
}
func sqliteDatabasePopulated(path string) bool {
stat, err := os.Stat(path)
return err == nil && stat.Size() > 0
}
func legacyBoltDatabasePopulated(path string) bool {
stat, err := os.Stat(path)
return err == nil && stat.Size() > 0
}
// migrateFromBoltToSQLite migrates essential data from BoltDB to SQLite
func migrateFromBoltToSQLite() error {
oldDBPath := settings.Config.Server.DatabaseV2.MigrateFrom
newDBPath := settings.Config.Server.DatabaseV2.Path
logger.Info("========================================")
logger.Info("Starting migration from legacy database")
logger.Info("========================================")
// Open old BoltDB (read-only)
logger.Info("Opening old database...")
oldDB, err := storm.Open(oldDBPath)
if err != nil {
return fmt.Errorf("failed to open old database: %w", err)
}
logger.Info("✓ Old database opened")
// Initialize new database
logger.Info("Initializing new database...")
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(newDBPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
oldDB.Close()
return fmt.Errorf("failed to initialize new database: %w", err)
}
logger.Info("✓ New database initialized")
// Ensure cleanup on error - remove incomplete database for retry
migrationSuccess := false
defer func() {
oldDB.Close()
sqlStore.Close()
if !migrationSuccess {
// Migration failed - remove the new database so it can be retried
logger.Warning("Migration failed, cleaning up new database file for retry...")
if err := os.Remove(newDBPath); err != nil && !os.IsNotExist(err) {
logger.Errorf("Failed to remove incomplete database file: %v", err)
} else {
logger.Info("✓ Incomplete database file removed, migration can be retried on next startup")
}
}
}()
// Migrate auth signing key from legacy Bolt settings
logger.Info("Migrating auth signing key...")
if err := migrateAuthSigningKey(oldDB, sqlStore); err != nil {
return fmt.Errorf("failed to migrate auth signing key: %w", err)
}
// Migrate users (bolt User.ID is stored as SQLite users.user_id)
logger.Info("Migrating users...")
if err := migrateUsers(oldDB, sqlStore); err != nil {
return fmt.Errorf("failed to migrate users: %w", err)
}
if err := state.EnsureNoAuthAdminUserAfterMigration(sqlStore); err != nil {
return fmt.Errorf("failed to configure noauth admin user: %w", err)
}
// Migrate shares
logger.Info("Migrating shares...")
if err := migrateShares(oldDB, sqlStore); err != nil {
return fmt.Errorf("failed to migrate shares: %w", err)
}
// Migrate access rules and groups
logger.Info("Migrating access rules and groups...")
if err := migrateAccessRules(oldDB, sqlStore); err != nil {
return fmt.Errorf("failed to migrate access rules: %w", err)
}
// Migrate index info
logger.Info("Migrating index info...")
if err := migrateIndexInfo(oldDB, sqlStore); err != nil {
return fmt.Errorf("failed to migrate index info: %w", err)
}
// Mark migration as successful
migrationSuccess = true
logger.Info("========================================")
logger.Info("Migration completed successfully!")
logger.Info("========================================")
logger.Infof("Your legacy database file is unchanged at: %s", oldDBPath)
logger.Infof("New SQLite database created at: %s", newDBPath)
return nil
}
func migrateAuthSigningKey(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
key, err := state.AuthSigningKeyFromBoltDB(oldDB)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
logger.Info(" No legacy auth signing key in Bolt settings")
return nil
}
return fmt.Errorf("read legacy auth signing key: %w", err)
}
if err := sqlStore.SaveSetting("auth.signingKey", base64.StdEncoding.EncodeToString([]byte(key))); err != nil {
return fmt.Errorf("save auth signing key: %w", err)
}
logger.Info(" ✓ Migrated auth signing key from legacy database")
return nil
}
// normalizeUserScopesBeforeSQLite copies scopes from FrontendScopes into BackendScopes when the Bolt
// record only had JSON key "scopes" (FrontendUser) and nothing under backendScopes — CreateUser
// persists only BackendScopes in SQLite user_data.
func normalizeUserScopesBeforeSQLite(user *users.User) error {
if len(user.BackendScopes) > 0 {
return nil
}
backendScopes, err := users.APIScopesToBackend(user.FrontendScopes)
if err != nil {
return fmt.Errorf("convert frontend scopes to backend: %w", err)
}
for i := range backendScopes {
backendScopes[i].Permissions = users.SourceFilePermissions{
View: true,
Download: user.Permissions.Download,
Modify: user.Permissions.Modify,
Delete: user.Permissions.Delete,
Create: user.Permissions.Create,
}
}
user.FrontendScopes = nil
user.BackendScopes = backendScopes
return nil
}
// migrateUsers migrates all users from BoltDB to SQLite. Each bolt user.ID is written as user_id
func migrateUsers(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
var usersList []*users.User
err := oldDB.All(&usersList)
if err != nil {
if err.Error() == "not found" {
logger.Info(" No users to migrate")
return nil
}
return fmt.Errorf("failed to read users from old DB: %w", err)
}
promoted := 0
for _, user := range usersList {
oldScopesCount := len(user.FrontendScopes)
newScopesCount := len(user.BackendScopes)
if err := normalizeUserScopesBeforeSQLite(user); err != nil {
return fmt.Errorf("failed to normalize scopes for user %s: %w", user.Username, err)
}
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
}
if merged, changed := usersidebar.EnsureSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes); changed {
user.SidebarLinks = merged
}
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
}
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.
user.Version = prevVersion
users.MigrateToSourcePermissions(user)
}
normalizeUserTokensBeforeSQLite(user)
promoteLegacyApiKeysBeforeSQLite(user)
updateTokens(user)
user.Version = users.NewestUserVersion
if newScopesCount > oldScopesCount {
promoted++
logger.Infof(" user %q: legacy database had %d scopes, SQLite now has %d",
user.Username, oldScopesCount, newScopesCount)
}
boltID := user.ID
if err := sqlStore.CreateUser(user); err != nil {
return fmt.Errorf("failed to save user %s (bolt id: %d): %w", user.Username, boltID, err)
}
if err := state.BackfillUserTokenHashesOnStore(sqlStore, user); err != nil {
return fmt.Errorf("failed to backfill token hashes for user %s: %w", user.Username, err)
}
}
if promoted > 0 {
logger.Infof(" Promoted frontend-only Bolt scopes to backend for %d user(s) before SQLite insert", promoted)
}
logger.Infof(" ✓ Migrated %d users", len(usersList))
return nil
}
// normalizeUserTokensBeforeSQLite ensures Bolt name-keyed tokens have Name set so
// TokensForPersist retains them in SQLite user_data (Bolt often stores the name only as the map key).
func normalizeUserTokensBeforeSQLite(user *users.User) {
if len(user.Tokens) == 0 {
return
}
normalized := make(map[string]users.AuthToken)
for key, token := range user.Tokens {
name := token.Name
if name == "" {
if key == token.Token {
continue
}
name = key
}
if _, exists := normalized[name]; exists {
continue
}
token.Name = name
if token.Token == "" {
token.Token = token.Key
}
token.Permissions = users.SanitizeTokenPermissions(token.Permissions)
users.StoreToken(normalized, token)
}
user.Tokens = normalized
}
// promoteLegacyApiKeysBeforeSQLite merges Bolt apiKeys entries into Tokens so
// they survive the SQLite migration and the hashed-tokens backfill (strict
// auth ignores BelongsTo and requires a hash row per raw JWT).
func promoteLegacyApiKeysBeforeSQLite(user *users.User) {
if len(user.ApiKeys) == 0 {
return
}
if user.Tokens == nil {
user.Tokens = make(map[string]users.AuthToken)
}
for name, token := range user.ApiKeys {
if token.Name == "" {
token.Name = name
}
if token.Token == "" {
token.Token = token.Key
}
if token.Token == "" {
continue
}
if _, exists := user.Tokens[token.Name]; exists {
continue
}
token.Permissions = users.SanitizeTokenPermissions(token.Permissions)
users.StoreToken(user.Tokens, token)
}
}
// migrateShares migrates all shares from BoltDB to SQLite.
//
// Legacy Bolt "Link" / "Share" JSON (flat CommonShare + Link fields):
// - "source" → Share.SourcePath (backend source path; not the share item path)
// - "path" → Share.Path (index-relative path within that source)
// - "userID" → Share.UserID unchanged (same numeric id as migrated users in SQLite)
// - "password_hash" → Share.PasswordHash (username is never stored on the share row)
//
// New users created after migration get random uint64 ids (utils.RandomUint64ID); Bolt ids 1,2,3…
// remain valid. PrepForFrontend resolves Share.UserID to username for API responses only.
func migrateShares(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
boltShareBuckets := []string{"Share", "Link"}
var sharesList []*share.LegacyShare
for _, bucket := range boltShareBuckets {
var batch []*share.LegacyShare
err := oldDB.Select().Bucket(bucket).Find(&batch)
if err != nil && err != storm.ErrNotFound {
return fmt.Errorf("failed to read shares from legacy DB bucket %q: %w", bucket, err)
}
if len(batch) > 0 {
sharesList = append(sharesList, batch...)
}
}
if len(sharesList) == 0 {
logger.Info(" No shares to migrate")
return nil
}
for _, legacy := range sharesList {
link := legacy.ToShare()
if link.UserID == 0 {
return fmt.Errorf("failed to save share %s: owner user id is missing", link.Hash)
}
// Legacy Bolt "source" → SourcePath; normalize when it was stored as a display name.
if link.SourcePath != "" {
if _, ok := settings.Config.Server.SourceMap[link.SourcePath]; !ok {
if src, ok := settings.Config.Server.NameToSource[link.SourcePath]; ok {
link.SourceName = src.Name
link.SourcePath = src.Path
}
} else if link.SourceName == "" {
link.SourceName = settings.Config.Server.SourceMap[link.SourcePath].Name
}
}
// Do not persist API-only fields; PrepForFrontend fills these on read.
link.ShareURL = ""
link.DownloadURL = ""
link.FrontendShareInfo.SourceURL = ""
link.CanEditShare = false
if err := sqlStore.SaveShare(&link); err != nil {
return fmt.Errorf("failed to save share %s: %w", link.Hash, err)
}
}
logger.Infof(" ✓ Migrated %d shares", len(sharesList))
return nil
}
// migrateAccessRules migrates access control data from BoltDB to SQLite.
// Path rules (AllRules) and group definitions (Groups) are unchanged through migration
func migrateAccessRules(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
// Read the access rules JSON blob from BoltDB
var data []byte
err := oldDB.Get("access_rules", "rules", &data)
if err != nil {
if err.Error() == "not found" {
logger.Info(" No access rules to migrate")
return nil
}
return fmt.Errorf("failed to read access rules: %w", err)
}
// Unmarshal the data
type dbStorage struct {
AllRules access.SourceRuleMap `json:"all_rules"`
Groups access.GroupMap `json:"groups"`
RevokedTokens map[string]struct{} `json:"revoked_tokens"`
HashedTokens map[string]uint `json:"hashed_tokens"`
}
var storage dbStorage
if err := json.Unmarshal(data, &storage); err != nil {
return fmt.Errorf("failed to unmarshal access rules: %w", err)
}
// Migrate access rules
ruleCount := 0
for source, rules := range storage.AllRules {
resolvedSource := source
if info, ok := users.ResolveSourceKey(source); ok {
resolvedSource = info.Path
} else if src, ok := settings.Config.Server.NameToSource[source]; ok {
resolvedSource = src.Path
}
for path, rule := range rules {
err := sqlStore.SaveAccessRule(resolvedSource, path, rule)
if err != nil {
return fmt.Errorf("failed to save access rule: %w", err)
}
ruleCount++
}
}
logger.Infof(" ✓ Migrated %d access rules", ruleCount)
// Migrate groups
for name, members := range storage.Groups {
err := sqlStore.SaveGroup(name, members)
if err != nil {
return fmt.Errorf("failed to save group %s: %w", name, err)
}
}
logger.Infof(" ✓ Migrated %d groups", len(storage.Groups))
// Migrate revoked tokens
for tokenHash := range storage.RevokedTokens {
err := sqlStore.SaveRevokedToken(tokenHash, 0)
if err != nil {
return fmt.Errorf("failed to save revoked token: %w", err)
}
}
logger.Infof(" ✓ Migrated %d revoked tokens", len(storage.RevokedTokens))
// Migrate hashed tokens (bolt stored owner user id); legacy rows are named API tokens.
for tokenHash, userID := range storage.HashedTokens {
if userID == 0 {
logger.Warningf(" skipping hashed token: invalid user id 0")
continue
}
err := sqlStore.SaveHashedToken(tokenHash, uint64(userID), false, 0)
if err != nil {
return fmt.Errorf("failed to save hashed token: %w", err)
}
}
logger.Infof(" ✓ Migrated %d hashed tokens", len(storage.HashedTokens))
return nil
}
// migrateIndexInfo migrates index info metadata from BoltDB to SQLite
func migrateIndexInfo(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
var indexList []*dbindex.IndexInfo
err := oldDB.All(&indexList)
if err != nil {
if err.Error() == "not found" {
logger.Info(" No index info to migrate")
return nil
}
return fmt.Errorf("failed to read index info: %w", err)
}
for _, info := range indexList {
err := sqlStore.SaveIndexInfo(info)
if err != nil {
return fmt.Errorf("failed to save index info for %s: %w", info.Path, err)
}
}
logger.Infof(" ✓ Migrated %d index info entries", len(indexList))
return nil
}
+165
View File
@@ -0,0 +1,165 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestValidateDatabasePaths_missingBoltOnFreshInstall(t *testing.T) {
dir := t.TempDir()
settings.Config.Server.DatabaseV2.MigrateFrom = filepath.Join(dir, "database.db.old")
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
err := validateDatabasePaths()
if err == nil {
t.Fatal("expected error when migrateFrom file is missing on fresh install")
}
}
func TestValidateDatabasePaths_emptyBoltOnFreshInstall(t *testing.T) {
dir := t.TempDir()
boltPath := filepath.Join(dir, "database.db.old")
if err := os.WriteFile(boltPath, nil, 0o600); err != nil {
t.Fatal(err)
}
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
err := validateDatabasePaths()
if err == nil {
t.Fatal("expected error when migrateFrom file is empty on fresh install")
}
}
func TestValidateDatabasePaths_missingBoltWhenSQLiteExists(t *testing.T) {
dir := t.TempDir()
settings.Config.Server.DatabaseV2.MigrateFrom = filepath.Join(dir, "missing.db.old")
sqlitePath := filepath.Join(dir, "filebrowser.sqlite")
if err := os.WriteFile(sqlitePath, []byte("sqlite"), 0o600); err != nil {
t.Fatal(err)
}
settings.Config.Server.DatabaseV2.Path = sqlitePath
if err := validateDatabasePaths(); err == nil {
t.Fatal("expected error when migrateFrom is set but legacy file is missing after migration")
}
}
func TestValidateDatabasePaths_okWhenSQLiteExistsWithoutMigrateFrom(t *testing.T) {
dir := t.TempDir()
settings.Config.Server.DatabaseV2.MigrateFrom = ""
sqlitePath := filepath.Join(dir, "filebrowser.sqlite")
if err := os.WriteFile(sqlitePath, []byte("sqlite"), 0o600); err != nil {
t.Fatal(err)
}
settings.Config.Server.DatabaseV2.Path = sqlitePath
if err := validateDatabasePaths(); err != nil {
t.Fatalf("expected no error: %v", err)
}
if checkMigrationNeeded() {
t.Fatal("migration should not run when sqlite already exists")
}
}
func TestValidateDatabasePaths_okWhenSQLiteAndBoltExist(t *testing.T) {
dir := t.TempDir()
boltPath := filepath.Join(dir, "database.db.old")
if err := os.WriteFile(boltPath, []byte("bolt"), 0o600); err != nil {
t.Fatal(err)
}
sqlitePath := filepath.Join(dir, "filebrowser.sqlite")
if err := os.WriteFile(sqlitePath, []byte("sqlite"), 0o600); err != nil {
t.Fatal(err)
}
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
settings.Config.Server.DatabaseV2.Path = sqlitePath
if err := validateDatabasePaths(); err != nil {
t.Fatalf("expected no error when both databases exist: %v", err)
}
if checkMigrationNeeded() {
t.Fatal("migration should not run when sqlite already exists")
}
}
func TestValidateDatabasePaths_rejectsDatabaseDBAsSQLitePath(t *testing.T) {
dir := t.TempDir()
settings.Config.Server.DatabaseV2.MigrateFrom = ""
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "database.db")
if err := validateDatabasePaths(); err == nil {
t.Fatal("expected error when sqlite path is database.db on fresh install")
}
}
func TestValidateDatabasePaths_rejectsUnrenamedLegacyDatabaseDB(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
settings.Config.Server.DatabaseV2.MigrateFrom = ""
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
if err := os.WriteFile("database.db", []byte("legacy-bolt"), 0o600); err != nil {
t.Fatal(err)
}
if err := validateDatabasePaths(); err == nil {
t.Fatal("expected error when unrenamed database.db exists on fresh install")
}
}
func TestValidateDatabasePaths_migrateFromDefaultResolvesFromEnv(t *testing.T) {
dir := t.TempDir()
boltPath := filepath.Join(dir, "database.db.old")
if err := os.WriteFile(boltPath, []byte("bolt"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("FILEBROWSER_DATABASE", boltPath)
settings.Config.Server.DatabaseV2.MigrateFrom = settings.MigrateFromDefault
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
if err := settings.ResolveDatabasePaths(); err != nil {
t.Fatalf("ResolveDatabasePaths: %v", err)
}
if settings.Config.Server.DatabaseV2.MigrateFrom != boltPath {
t.Fatalf("migrateFrom = %q, want %q", settings.Config.Server.DatabaseV2.MigrateFrom, boltPath)
}
if err := validateDatabasePaths(); err != nil {
t.Fatalf("validateDatabasePaths: %v", err)
}
if !checkMigrationNeeded() {
t.Fatal("expected migration to be needed")
}
}
func TestCheckMigrationNeeded_trueWhenBoltPresentAndSQLiteMissing(t *testing.T) {
dir := t.TempDir()
boltPath := filepath.Join(dir, "database.db.old")
if err := os.WriteFile(boltPath, []byte("bolt"), 0o600); err != nil {
t.Fatal(err)
}
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
if err := validateDatabasePaths(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !checkMigrationNeeded() {
t.Fatal("expected migration to be needed")
}
}
func TestValidateDatabasePaths_okFreshInstallWithoutMigrateFrom(t *testing.T) {
dir := t.TempDir()
settings.Config.Server.DatabaseV2.MigrateFrom = ""
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
if err := validateDatabasePaths(); err != nil {
t.Fatalf("expected no error on fresh install without migrateFrom: %v", err)
}
}
+160
View File
@@ -0,0 +1,160 @@
package cmd
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestMigrateAdminTokensRoundTripSQLite(t *testing.T) {
boltPath := settingsMigrationBoltPath(t)
sqlitePath := filepath.Join(t.TempDir(), "migrate-tokens.sqlite")
oldDB, err := storm.Open(boltPath)
if err != nil {
t.Fatal(err)
}
defer oldDB.Close()
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(sqlitePath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
t.Fatal(err)
}
defer sqlStore.Close()
var list []*users.User
if err = oldDB.All(&list); err != nil {
t.Fatal(err)
}
var admin *users.User
for _, u := range list {
if u.Username == "admin" {
admin = u
break
}
}
if admin == nil {
t.Fatal("admin not found")
}
normalizeUserTokensBeforeSQLite(admin)
if err = sqlStore.CreateUser(admin); err != nil {
t.Fatal(err)
}
loaded, err := sqlStore.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
customized, ok := loaded.Tokens["customized"]
if !ok {
t.Fatalf("customized missing after reload: %#v", loaded.Tokens)
}
if !customized.Permissions.Admin {
t.Fatalf("customized admin permission missing after reload: %#v", customized.Permissions)
}
if customized.Permissions.Api || customized.Permissions.Share || customized.Permissions.Realtime {
t.Fatalf("customized should only retain admin global after reload: %#v", customized.Permissions)
}
if customized.Permissions.Modify || customized.Permissions.Create || customized.Permissions.Delete || customized.Permissions.Download {
t.Fatalf("customized legacy file ops should be stripped: %#v", customized.Permissions)
}
out, _ := json.MarshalIndent(map[string]any{
"customizedPermissions": customized.Permissions,
"tokenNames": len(loaded.Tokens),
}, "", " ")
os.Stdout.Write(out)
os.Stdout.Write([]byte("\n"))
}
func TestPromoteLegacyApiKeysBeforeSQLite(t *testing.T) {
user := &users.User{
FrontendUser: users.FrontendUser{Username: "legacy-tokens"},
Tokens: map[string]users.AuthToken{},
}
user.ApiKeys = map[string]users.AuthToken{
"legacy-sync": {Key: "raw-jwt-1"},
"empty": {},
}
user.Tokens["obsidian"] = users.AuthToken{Name: "obsidian", Token: "raw-jwt-existing"}
promoteLegacyApiKeysBeforeSQLite(user)
// Existing Tokens entries win; empty raw tokens are skipped.
if got := user.Tokens["obsidian"].Token; got != "raw-jwt-existing" {
t.Fatalf("existing token must win, got %q", got)
}
if got := user.Tokens["legacy-sync"].Token; got != "raw-jwt-1" {
t.Fatalf("promoted token must carry raw JWT, got %q", got)
}
if _, ok := user.Tokens["raw-jwt-1"]; !ok {
t.Fatal("expected raw-JWT alias key after promotion")
}
if _, ok := user.Tokens["empty"]; ok {
t.Fatal("token without raw material must be skipped")
}
}
func TestUpdateTokenHashBackfillSuccessStampsVersion(t *testing.T) {
user := &users.User{FrontendUser: users.FrontendUser{Username: "no-tokens"}}
user.Version = users.ProfileStorageVersion
changed, failed := updateTokenHashBackfill(user)
if failed {
t.Fatal("tokenless user must not fail backfill")
}
if !changed {
t.Fatal("expected version bump to count as changed")
}
if user.Version != users.TokenHashBackfillVersion {
t.Fatalf("version = %d, want %d", user.Version, users.TokenHashBackfillVersion)
}
}
func TestUpdateTokenHashBackfillFailureSkipsBump(t *testing.T) {
// Force token hash registration to fail; the version must stay below
// newest so the backfill retries on next startup.
orig := addApiToken
addApiToken = func(string, uint64) error {
return errors.New("simulated token registration failure")
}
t.Cleanup(func() { addApiToken = orig })
user := &users.User{FrontendUser: users.FrontendUser{Username: "backfill-retry"}}
user.Version = users.ProfileStorageVersion
user.ApiKeys = map[string]users.AuthToken{
"legacy": {Key: "raw-jwt-retry"},
}
_, failed := updateTokenHashBackfill(user)
if !failed {
t.Fatal("expected failure when token registration fails")
}
if user.Version != users.ProfileStorageVersion {
t.Fatalf("failed backfill must not bump version, got %d", user.Version)
}
if bumpToNewestVersion(user, failed) {
t.Fatal("catch-all must not bump past a failed migration")
}
}
func TestBumpToNewestVersion(t *testing.T) {
user := &users.User{FrontendUser: users.FrontendUser{Username: "stale"}}
user.Version = users.ProfileStorageVersion
if !bumpToNewestVersion(user, false) {
t.Fatal("expected bump for stale version without failure")
}
if user.Version != users.NewestUserVersion {
t.Fatalf("version = %d, want %d", user.Version, users.NewestUserVersion)
}
if bumpToNewestVersion(user, false) {
t.Fatal("must not bump when already newest")
}
}
@@ -0,0 +1,92 @@
package cmd
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// Simulates migrateUsers() steps for a Bolt user without backend scopes or source permissions.
func TestMigrateUsersFlow_appliesConfigDefaultsAndSourcePermissions(t *testing.T) {
loadMigrateTestConfig(t, "../../_docker/src/jwt/backend/config.yaml")
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "legacy-bolt-user",
Permissions: users.Permissions{
Download: true,
Modify: true,
Create: true,
Share: true,
},
},
Version: 2,
}
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
if !users.MigrateToSourcePermissions(user) {
t.Fatal("expected source permission migration")
}
}
user.Version = users.ProfileStorageVersion
if !user.Permissions.Share {
t.Fatal("global share should remain after migration")
}
if user.Permissions.Modify || user.Permissions.Download {
t.Fatalf("legacy global file ops should be cleared: %+v", user.Permissions)
}
if len(user.BackendScopes) == 0 {
t.Fatal("ApplyUserDefaults should assign default-enabled sources")
}
perms := user.BackendScopes[0].Permissions
if !perms.View || !perms.Modify || !perms.Create || !perms.Download {
t.Fatalf("scope permissions after migration: %+v", perms)
}
if user.BackendScopes[0].Scope != "/legacy-bolt-user" {
t.Fatalf("scope after applying migration defaults=%q", user.BackendScopes[0].Scope)
}
}
func TestMigrateUsersFlow_existingScopeGetsLegacyGlobalFileOps(t *testing.T) {
loadMigrateTestConfig(t, "../../_docker/src/proxy/backend/config.yaml")
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "scoped",
Permissions: users.Permissions{
Download: true,
Modify: false,
Create: false,
Share: true,
},
},
BackendScopes: []users.BackendScope{
{Path: settings.Config.Server.Sources[0].Path, Scope: "/"},
},
Version: 2,
}
users.MigrateToSourcePermissions(user)
perms := user.BackendScopes[0].Permissions
if !perms.Download || perms.Modify || perms.Create {
t.Fatalf("expected legacy global copied to scope: %+v", perms)
}
}
func loadMigrateTestConfig(t *testing.T, path string) {
t.Helper()
settings.Config = settings.SetDefaults(true)
if err := settings.LoadConfigWithDefaultsForTest(path); err != nil {
t.Fatal(err)
}
}
+142
View File
@@ -0,0 +1,142 @@
package cmd
import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestMigrationFixturePostState(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
alignSettingsSourcesForMigrationFixture(t)
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
t.Fatal(err)
}
defer sqlStore.Close()
playwrightPath := fixturePlaywrightSource
dockerPath := fixtureDockerSource
accessPath := fixtureAccessSource
admin, err := sqlStore.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if !admin.Permissions.Admin || !admin.Permissions.Api || !admin.Permissions.Share {
t.Fatalf("admin globals=%+v", admin.Permissions)
}
if admin.Permissions.Realtime {
t.Fatal("admin realtime should be false")
}
fullPerms := users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
}
for _, sourcePath := range []string{playwrightPath, dockerPath, accessPath} {
assertSourceFilePerms(t, admin, sourcePath, fullPerms)
}
customized, ok := admin.Tokens["customized"]
if !ok {
t.Fatal("customized token missing")
}
if !customized.Permissions.Admin {
t.Fatalf("customized token globals=%+v", customized.Permissions)
}
if customized.Permissions.Api || customized.Permissions.Share || customized.Permissions.Realtime {
t.Fatalf("customized token should only have admin global: %+v", customized.Permissions)
}
if customized.Permissions.Modify || customized.Permissions.Create || customized.Permissions.Delete || customized.Permissions.Download {
t.Fatalf("customized token legacy file ops should be stripped: %+v", customized.Permissions)
}
basic, err := sqlStore.GetUserByUsername("basic")
if err != nil {
t.Fatal(err)
}
if !basic.Permissions.Share || basic.Permissions.Admin || basic.Permissions.Api {
t.Fatalf("basic globals=%+v", basic.Permissions)
}
viewOnly := users.SourceFilePermissions{View: true}
for _, sourcePath := range []string{playwrightPath, dockerPath, accessPath} {
assertSourceFilePerms(t, basic, sourcePath, viewOnly)
}
graham, err := sqlStore.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
grahamPerms := users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: false,
}
assertSourceFilePerms(t, graham, playwrightPath, grahamPerms)
assertSourceFilePerms(t, graham, dockerPath, grahamPerms)
allRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
playwrightRules := allRules[playwrightPath]
if rule, ok := playwrightRules["/text-files/bash.sh/"]; !ok || len(rule.Deny.Users) != 1 {
t.Fatalf("playwright access rule=%+v ok=%v", rule, ok)
} else if _, denied := rule.Deny.Users["admin"]; !denied {
t.Fatalf("playwright access rule deny=%+v", rule.Deny.Users)
}
accessRules := allRules[accessPath]
if len(accessRules) != 1 {
t.Fatalf("access source rule count=%d", len(accessRules))
}
if rule, ok := accessRules["/"]; !ok || len(rule.Deny.Users) != 1 {
t.Fatalf("access source rule=%+v ok=%v", rule, ok)
} else if _, denied := rule.Deny.Users["basic"]; !denied {
t.Fatalf("access source rule deny=%+v", rule.Deny.Users)
}
shares, err := sqlStore.GetSharesByUserID(admin.ID)
if err != nil {
t.Fatal(err)
}
if len(shares) != 2 {
t.Fatalf("share count=%d", len(shares))
}
shareByHash := make(map[string]*share.Share, len(shares))
for _, s := range shares {
shareByHash[s.Hash] = s
}
if link, ok := shareByHash["lMhwHkF-hqCN92-QIJJZow"]; !ok || link.Path != "/myfolder/" ||
!link.AllowModify || !link.AllowCreate || !link.AllowDelete {
t.Fatalf("myfolder share=%+v", link)
}
if link, ok := shareByHash["dGhQi4AcMhva2Ne-7x7fvw"]; !ok || link.Path != "/test & test.txt/" ||
link.AllowModify || link.AllowCreate || link.AllowDelete {
t.Fatalf("test share=%+v", link)
}
}
func assertSourceFilePerms(t *testing.T, user *users.User, sourcePath string, want users.SourceFilePermissions) {
t.Helper()
got, ok := user.BackendSourcePermissions[sourcePath]
if !ok {
t.Fatalf("user %q missing source permissions for %q", user.Username, sourcePath)
}
if got != want {
t.Fatalf("user %q source %q perms=%+v want=%+v", user.Username, sourcePath, got, want)
}
}
+124
View File
@@ -0,0 +1,124 @@
package cmd
import (
"path/filepath"
"testing"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func writeSidebarMigrationBolt(t *testing.T, path string) {
t.Helper()
db, err := storm.Open(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
user := &users.User{
ID: 1,
FrontendUser: users.FrontendUser{
NonAdminEditable: users.NonAdminEditable{
Password: "$2a$10$IYCsziHjzH0mPc.bZwRuXefQKPVXfFqjdyfVmNcL.XZJsgyfxljDy",
SidebarLinks: []users.SidebarLink{
{
Name: "My Files",
Category: string(users.SidebarLinkSourceMinimal),
Icon: "folder",
SourceName: fixturePlaywrightSource,
Target: "/",
},
{
Name: "External Docs",
Category: string(users.SidebarLinkCustom),
Target: "https://example.com/docs",
Icon: "link",
},
},
},
Username: "sidebaruser",
LoginMethod: users.LoginMethodPassword,
FrontendScopes: []users.FrontendScope{
{Name: fixturePlaywrightSource, Scope: "/"},
{Name: fixtureDockerSource, Scope: "/"},
},
Permissions: users.Permissions{
Download: true,
},
},
Version: 3,
}
if err := db.Save(user); err != nil {
t.Fatal(err)
}
}
func TestMigrationSidebarLinks_customMinimalPartialScopesAndCustomURL(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
alignSettingsSourcesForMigrationFixture(t)
boltPath := filepath.Join(t.TempDir(), "sidebar-migration.db.old")
writeSidebarMigrationBolt(t, boltPath)
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
t.Fatal(err)
}
defer sqlStore.Close()
sidebarUser, err := sqlStore.GetUserByUsername("sidebaruser")
if err != nil {
t.Fatal(err)
}
links := sidebarUser.SidebarLinks
if len(links) < 3 {
t.Fatalf("sidebaruser links=%#v, want at least 3 after scope merge", links)
}
var minimalLink *users.SidebarLink
var customLink *users.SidebarLink
var dockerLink *users.SidebarLink
for i := range links {
link := links[i]
switch {
case link.Category == string(users.SidebarLinkSourceMinimal):
minimalLink = &links[i]
case link.Category == string(users.SidebarLinkCustom):
customLink = &links[i]
case link.SourceName == fixtureDockerSource || link.Name == "docker":
dockerLink = &links[i]
}
}
if minimalLink == nil {
t.Fatalf("missing source-minimal link in %#v", links)
}
if minimalLink.Name != "My Files" || minimalLink.Icon != "folder" {
t.Fatalf("custom minimal link not preserved: %#v", *minimalLink)
}
if customLink == nil || customLink.Name != "External Docs" || customLink.Target != "https://example.com/docs" {
t.Fatalf("custom link not preserved: %#v", customLink)
}
if dockerLink == nil {
t.Fatalf("docker scope link missing after merge: %#v", links)
}
frontendLinks := usersidebar.FrontendLinks(links, true)
if len(frontendLinks) < 3 {
t.Fatalf("frontend links=%#v", frontendLinks)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package cmd
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
+64 -57
View File
@@ -11,53 +11,63 @@ import (
_ "net/http/pprof"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/icons"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/analytics"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/icons"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
"github.com/gtsteffaniak/filebrowser/backend/internal/web"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/swaggo/swag"
)
var store *bolt.BoltStore
var runtimeApp *app.App
func getStore(configFile string) bool {
// Use the config file (global flag)
settings.Initialize(configFile)
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
logger.Fatalf("could not load db info: %v", err)
func initializeDatabase(configFile string, cliMode bool) bool {
if cliMode {
settings.InitializeCLI(configFile)
} else {
settings.Initialize(configFile)
}
store = s
return hasDB
}
func generalUsage() {
fmt.Printf(`usage: ./filebrowser <command> [options]
commands:
-h Print help
-c Print the default config file
version Print version information
set -u Username and password for the new user
set -a Create user as admin
set -s Specify a user scope
set -h Print this help message
`)
if err := validateDatabasePaths(); err != nil {
logger.Fatalf("%v", err)
}
if checkMigrationNeeded() {
logger.Info("Old database detected, starting migration...")
err := migrateFromBoltToSQLite()
if err != nil {
logger.Fatalf("Migration failed: %v", err)
}
}
store, existingDb, err := state.Open(settings.Config.Server.DatabaseV2.Path)
if err != nil {
logger.Fatalf("could not initialize state: %v", err)
}
runtimeApp, err = app.WireServices(store)
if err != nil {
logger.Fatalf("could not wire services: %v", err)
}
return existingDb
}
func StartFilebrowser() {
keepGoing := runCLI()
keepGoing, dbExists := runCLI()
if !keepGoing {
return
}
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.DatabaseV2.Path)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.DatabaseV2.Path)
}
if !settings.Config.Server.DisableUpdateCheck {
info, _ := utils.CheckForUpdates()
if info.LatestVersion != "" {
@@ -76,15 +86,10 @@ func StartFilebrowser() {
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
dbExists := getStore(configPath)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
// Dev mode enables development features like template hot-reloading
_, err := os.Stat("http/dist")
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
_, err := os.Stat("internal/web/dist")
// In dev mode, always use filesystem assets. Otherwise, check if internal/web/dist exists
if !settings.Env.IsDevMode {
settings.Env.EmbeddedFs = os.IsNotExist(err)
}
@@ -115,7 +120,7 @@ func StartFilebrowser() {
}
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL
swagInfo.BasePath = settings.Config.Http.BaseURL
swag.Register(docs.SwaggerInfo.InstanceName(), swagInfo)
// initialize indexing and schedule indexing ever n minutes (default 5)
if len(settings.Config.Server.SourceMap) == 0 {
@@ -128,26 +133,24 @@ func StartFilebrowser() {
logger.Fatalf("Failed to initialize index database: %v", err)
}
// Set indexing storage for persistence
if store != nil && store.Indexing != nil {
indexing.SetIndexingStorage(store.Indexing)
if isNewDb {
if err := store.Indexing.ResetAllComplexities(); err != nil {
logger.Errorf("Failed to reset index complexities: %v", err)
}
// Index metadata persistence is wired from app.WireServices during initializeDatabase.
if isNewDb {
if err := state.ResetAllIndexComplexities(); err != nil {
logger.Errorf("Failed to reset index complexities: %v", err)
}
}
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false, isNewDb)
}
analytics.StartReporter()
validateUserInfo(!dbExists)
validateOfficeIntegration()
validateAccessRules()
validateShareInfo()
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
if err := rootCMD(ctx, &serverConfig, runtimeApp, shutdownComplete); err != nil {
logger.Fatalf("Error starting filebrowser: %v", err)
}
close(done) // Signal that the server has stopped
@@ -177,27 +180,29 @@ func StartFilebrowser() {
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
}
<-shutdownComplete
if err := fileutils.ClearDirectoryContents(settings.DownloadCacheDir()); err != nil {
logger.Warningf("failed to clear download spool on shutdown: %v", err)
}
logger.Info("Shutdown complete.")
}
func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shutdownComplete chan struct{}) error {
if serverConfig.NumImageProcessors < 1 {
logger.Fatal("Image resize workers count could not be < 1")
}
cacheDir := settings.Config.Server.CacheDir
numWorkers := settings.Config.Server.NumImageProcessors
ffmpeg.SetFFmpegPaths()
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
embeddedAssets := fbhttp.GetEmbeddedAssets()
embeddedAssets := web.GetEmbeddedAssets()
subAssets, err := fs.Sub(embeddedAssets, "embed")
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
fileutils.InitAssetFS(subAssets, true)
fileutils.InitAssetFS(subAssets, true, false)
} else {
fileutils.InitAssetFS(nil, false)
fileutils.InitAssetFS(nil, false, settings.Env.IsDevMode)
}
// Start preview service
@@ -206,8 +211,6 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
logger.Fatalf("Error starting preview service: %v", err)
}
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
logger.Debugf("Exiftool Enabled : %v", settings.Config.Integrations.Media.ExiftoolPath != "")
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
@@ -217,6 +220,10 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
// Initialize PWA manifest after icons are generated
icons.InitializePWAManifest()
fbhttp.StartHttp(ctx, store, shutdownComplete)
web.StartHttp(ctx, web.Deps{
Store: a.Store,
Files: a.Files,
Auth: a.Auth,
}, shutdownComplete)
return nil
}
+25 -27
View File
@@ -1,18 +1,15 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/go-logger/logger"
)
// validateShareInfo migrates share links to add default sidebar links
func validateShareInfo() {
if store.Share == nil {
return
}
// Get all shares
shares, err := store.Share.All()
shares, err := state.GetAllShares()
if err != nil {
logger.Debugf("No shares found or error getting shares: %v", err)
return
@@ -22,27 +19,28 @@ func validateShareInfo() {
for _, link := range shares {
// Check if this share needs migration (version not set or 0)
if link.Version == 0 {
// Add default sidebar links
link.SidebarLinks = []users.SidebarLink{
{
Name: "Share QR Code and Info",
Category: "shareInfo",
Target: "#",
Icon: "qr_code",
},
{
Name: "Download",
Category: "download",
Target: "#",
Icon: "download",
},
}
// Set version to 1 to indicate migration is complete
link.Version = 1
hash := link.Hash
// Save the updated share
if err := store.Share.Save(link); err != nil {
if err := state.UpdateShare(hash, func(existingShare *share.Share) error {
// Set version to 1 to indicate migration is complete
existingShare.Version = 1
// Add default sidebar links
existingShare.SidebarLinks = []users.SidebarLink{
{
Name: "Share QR Code and Info",
Category: "shareInfo",
Target: "#",
Icon: "qr_code",
},
{
Name: "Download",
Category: "download",
Target: "#",
Icon: "download",
},
}
return nil
}); err != nil {
logger.Errorf("Failed to migrate share %s: %v", link.Hash, err)
continue
}
+183 -122
View File
@@ -6,110 +6,155 @@ import (
"reflect"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
var createBackup = false
// addApiToken registers a raw API token's hash with access storage. It is a
// variable so tests can inject a failing registration deterministically.
var addApiToken = state.AddApiToken
func validateUserInfo(newDB bool) {
// update source info for users if names/sources/paths might have changed
usersList, err := store.Users.Gets()
usersList, err := state.GetAllUsers()
if err != nil {
logger.Fatalf("could not load users: %v", err)
}
for _, user := range usersList {
for i := range usersList {
user := &usersList[i]
changedFields := make([]string, 0, 8)
changePass := false
updateUser := false
if updateUserScopes(user) {
updateUser = true
scopesChanged := updateUserScopes(user)
if scopesChanged {
changedFields = append(changedFields, "backendScopes")
}
if updatePermissions(user) {
updateUser = true
changedFields = append(changedFields, "permissions", "perm", "version")
}
if updateSourcePermissions(user) {
changedFields = append(changedFields, "backendScopes", "backendSourcePermissions", "version")
}
if updatePreviewSettings(user) {
updateUser = true
changedFields = append(changedFields, "preview")
}
if updateLoginType(user) {
updateUser = true
changedFields = append(changedFields, "loginMethod")
}
if updateShowFirstLogin(user) {
updateUser = true
changedFields = append(changedFields, "showFirstLogin")
}
if updateSidebarLinks(user) {
updateUser = true
if updateSidebarLinks(user, scopesChanged) {
changedFields = append(changedFields, "sidebarLinks")
}
if updateTokens(user) {
updateUser = true
changedFields = append(changedFields, "tokens", "version")
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
if user.Username == adminUser && adminPass != "" && passwordEnabled {
if normalizeApiTokenPermissions(user) {
changedFields = append(changedFields, "tokens")
}
tokenBackfillChanged, tokenBackfillFailed := updateTokenHashBackfill(user)
if tokenBackfillChanged && !tokenBackfillFailed {
changedFields = append(changedFields, "tokens", "version")
} else if tokenBackfillChanged {
changedFields = append(changedFields, "tokens")
}
if state.ApplyEnforcedSyncToUser(user) {
changedFields = append(changedFields, settings.UserJSONFieldsForEnforcedSync()...)
}
if state.ApplyEnforcedSourcePermissionsSyncToUser(user) {
changedFields = append(changedFields, "backendScopes", "backendSourcePermissions")
}
if bumpToNewestVersion(user, tokenBackfillFailed) {
changedFields = append(changedFields, "version")
}
adminUser := settings.PasswordAdminUsername()
if user.Username == adminUser && user.Permissions.Admin {
adminPerms := settings.AdminPerms()
if user.Permissions.Share != adminPerms.Share || user.Permissions.Api != adminPerms.Api {
user.Permissions.Share = adminPerms.Share
user.Permissions.Api = adminPerms.Api
user.Permissions.Admin = true
changedFields = append(changedFields, "permissions")
}
}
if user.Username == adminUser && settings.ConfigTriggersAdminPasswordReset() && user.LoginMethod == users.LoginMethodPassword {
logger.Info("Resetting admin user to default username and password.")
user.Permissions.Admin = true
user.Password = settings.Config.Auth.AdminPassword
updateUser = true
user.Permissions = settings.AdminPerms()
user.Password = settings.PasswordAdminPassword()
user.LoginMethod = users.LoginMethodPassword
changedFields = append(changedFields, "permissions", "password", "loginMethod")
changePass = true
}
if updateUser {
skipCreateBackup := os.Getenv("FILEBROWSER_DISABLE_AUTOMATIC_BACKUP") == "true" || newDB
if createBackup && !skipCreateBackup {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
err = fileutils.CopyFile(settings.Config.Server.Database, fmt.Sprintf("%s.bak", settings.Config.Server.Database))
if err != nil {
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
}
}
err := store.Users.Save(user, changePass, true)
changedFields = dedupeFields(changedFields)
if len(changedFields) == 0 {
continue
}
skipCreateBackup := os.Getenv("FILEBROWSER_DISABLE_AUTOMATIC_BACKUP") == "true" || newDB
if createBackup && !skipCreateBackup {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
err = fileutils.CopyFile(settings.Config.Server.DatabaseV2.Path, fmt.Sprintf("%s.bak", settings.Config.Server.DatabaseV2.Path))
if err != nil {
logger.Errorf("could not update user: %v", err)
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
}
}
plainPass := ""
if changePass {
plainPass = user.Password
}
if err := state.UpdateUser(user, plainPass, changedFields...); err != nil {
logger.Errorf("could not update user: %v", err)
}
}
}
func updateUserScopes(user *users.User) bool {
newScopes := []users.SourceScope{}
seen := make(map[string]struct{})
// Build map for existing scopes by Name
existing := make(map[string]users.SourceScope)
for _, s := range user.Scopes {
existing[s.Name] = s
}
// Preserve order by using Config.Server.Sources
for _, src := range settings.Config.Server.Sources {
existingScope, ok := existing[src.Path]
if ok {
// If scope is empty and there's a default, apply default
if existingScope.Scope == "" {
existingScope.Scope = src.Config.DefaultUserScope
}
} else if src.Config.DefaultEnabled {
existingScope.Scope = src.Config.DefaultUserScope
} else {
func dedupeFields(fields []string) []string {
seen := make(map[string]struct{}, len(fields))
out := make([]string, 0, len(fields))
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
newScopes = append(newScopes, users.SourceScope{
Name: src.Path,
Scope: existingScope.Scope,
})
seen[src.Path] = struct{}{}
key := strings.ToLower(field)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, field)
}
return out
}
// Preserve user-defined scopes not matching current sources, append to end
for _, s := range user.Scopes {
if _, ok := seen[s.Name]; !ok {
newScopes = append(newScopes, s)
func updateUserScopes(user *users.User) bool {
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes, user.DeclinedDefaultSources)
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
user.BackendScopes = newScopes
return changed
}
func updateSourcePermissions(user *users.User) bool {
changed := false
if user.Version < users.SourcePermissionsMigrationVersion {
if users.MigrateToSourcePermissions(user) {
changed = true
}
}
changed := !reflect.DeepEqual(user.Scopes, newScopes)
user.Scopes = newScopes
if users.EnsureSourcePermissionsForScopes(
user,
settings.DefaultSourceFilePermissions(),
settings.AdminSourceFilePermissions(),
) {
changed = true
}
return changed
}
@@ -123,7 +168,7 @@ func updateShowFirstLogin(user *users.User) bool {
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
if user.Version >= 1 {
if user.Version >= users.PermMigrationVersion {
return false
}
updateUser := true
@@ -169,7 +214,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = 2
user.Version = users.PermMigrationVersion
if updateUser {
createBackup = true
}
@@ -194,74 +239,90 @@ func updatePreviewSettings(user *users.User) bool {
return false
}
// updateSidebarLinks checks if user has stale source links and rebuilds them if needed
func updateSidebarLinks(user *users.User) bool {
// Count source links and check if any are still valid
sourceLinksCount := 0
validSourceLinksCount := 0
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
sourceLinksCount++
// Check if this source still exists
if link.SourceName != "" {
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
validSourceLinksCount++
}
}
}
// updateSidebarLinks normalizes saved links and adds defaults for new or changed scopes.
func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
links, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks)
if scopesChanged || user.SidebarLinks == nil {
links, changed = usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
}
// If user has no source links, don't update anything
if sourceLinksCount == 0 {
if !changed {
return false
}
// If user has source links but NONE are valid, rebuild from their scopes
if validSourceLinksCount == 0 {
logger.Infof("User %s has %d stale source links, rebuilding from scopes", user.Username, sourceLinksCount)
// Remove all existing source links
newLinks := []users.SidebarLink{}
for _, link := range user.SidebarLinks {
if !strings.HasPrefix(link.Category, "source") {
newLinks = append(newLinks, link)
}
}
// Add new source links based on user's scopes
for _, scope := range user.Scopes {
if source, ok := settings.Config.Server.SourceMap[scope.Name]; ok {
// User has access to this source, add it to sidebar
newLinks = append(newLinks, users.SidebarLink{
Name: source.Name,
Category: "source",
Target: "/",
Icon: "",
SourceName: source.Path,
})
}
}
user.SidebarLinks = newLinks
return true
}
// User has at least one valid source link, no update needed
return false
user.SidebarLinks = links
return true
}
func updateTokens(user *users.User) bool {
if user.Version >= 2 {
if user.Version >= users.TokenMigrationVersion {
return false
}
if user.ApiKeys != nil {
user.Tokens = make(map[string]users.AuthToken)
for name, token := range user.ApiKeys {
token.Token = token.Key
user.Tokens[name] = token
token.Name = name
users.StoreToken(user.Tokens, token)
}
}
user.Version = 2
user.Version = users.TokenMigrationVersion
return true
}
func normalizeApiTokenPermissions(user *users.User) bool {
if user == nil || len(user.Tokens) == 0 {
return false
}
changed := false
for name, token := range user.Tokens {
if token.Name == "" || name != token.Name {
continue
}
sanitized := users.SanitizeTokenPermissions(token.Permissions)
if sanitized != token.Permissions {
token.Permissions = sanitized
user.Tokens[name] = token
changed = true
}
}
return changed
}
// updateTokenHashBackfill promotes legacy ApiKeys into Tokens and registers a
// hashed_tokens row for every stored raw JWT so strict server-side auth (which
// ignores BelongsTo) accepts pre-2.0.8 tokens. Runs once per user via
// TokenHashBackfillVersion. It reports (changed, failed): when hash
// registration fails the version is left alone so callers skip the newest
// bump and the backfill retries on next startup.
func updateTokenHashBackfill(user *users.User) (bool, bool) {
if user == nil || user.Version >= users.TokenHashBackfillVersion {
return false, false
}
before := len(user.Tokens)
promoteLegacyApiKeysBeforeSQLite(user)
changed := len(user.Tokens) != before
hashesRegistered := true
for _, raw := range state.CollectStoredRawTokens(user) {
if err := addApiToken(raw, user.ID); err != nil {
logger.Errorf("could not register token hash for user %s: %v", user.Username, err)
hashesRegistered = false
}
}
if hashesRegistered {
user.Version = users.TokenHashBackfillVersion
createBackup = true
return true, false
}
return changed, true
}
// bumpToNewestVersion stamps the user at NewestUserVersion. It is skipped when
// a migration failed so the failed migration retries on next startup instead
// of being buried by the catch-all bump.
func bumpToNewestVersion(user *users.User, migrationFailed bool) bool {
if migrationFailed || user == nil || user.Version >= users.NewestUserVersion {
return false
}
user.Version = users.NewestUserVersion
return true
}
+124
View File
@@ -0,0 +1,124 @@
package cmd
import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestUpdateUserScopes_mergesMissingDefaultEnabled(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{Username: "graham"},
BackendScopes: []users.BackendScope{
{Path: fixturePlaywrightSource, Scope: "/myfolder"},
{Path: fixtureDockerSource, Scope: "/"},
},
}
if !updateUserScopes(user) {
t.Fatal("expected missing defaultEnabled access source to be merged")
}
if len(user.BackendScopes) != 3 {
t.Fatalf("scopes=%#v want 3 including access", user.BackendScopes)
}
found := false
for _, scope := range user.BackendScopes {
if scope.Path == fixtureAccessSource {
found = true
break
}
}
if !found {
t.Fatalf("access source should be added for defaultEnabled: %#v", user.BackendScopes)
}
// Preserve existing scope paths
for _, scope := range user.BackendScopes {
if scope.Path == fixturePlaywrightSource && scope.Scope != "/myfolder" {
t.Fatalf("playwright scope path changed: %#v", scope)
}
}
}
func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
alignSettingsSourcesForMigrationFixture(t)
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
t.Fatal(err)
}
defer sqlStore.Close()
graham, err := sqlStore.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if graham.BackendSourcePermissions == nil {
t.Fatal("expected backend source permissions after migration")
}
// Migration fixture itself may omit access; merge happens in updateUserScopes.
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; ok {
t.Fatalf("graham should not have access permissions from migration alone: %#v", graham.BackendSourcePermissions)
}
updateUserScopes(graham)
updateSourcePermissions(graham)
updateSidebarLinks(graham, true)
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
foundScope = true
break
}
}
if !foundScope {
t.Fatalf("graham should gain access scope after merge: %#v", graham.BackendScopes)
}
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; !ok {
t.Fatalf("graham should have access source permissions after merge: %#v", graham.BackendSourcePermissions)
}
foundLink := false
for _, link := range usersidebar.FrontendLinks(graham.SidebarLinks, true) {
if link.Name == "access" || link.SourceName == fixtureAccessSource {
foundLink = true
break
}
}
if !foundLink {
t.Fatalf("graham should have access sidebar link after merge: %#v", graham.SidebarLinks)
}
}
func TestUpdateUserScopes_seedsDefaultEnabledWhenEmpty(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{Username: "newuser"},
}
if !updateUserScopes(user) {
t.Fatal("expected default-enabled scopes to be seeded")
}
if len(user.BackendScopes) != 3 {
t.Fatalf("scopes=%#v want all default-enabled sources", user.BackendScopes)
}
}
+113
View File
@@ -0,0 +1,113 @@
package cmd
import (
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestUpdateSidebarLinks_dedupesDuplicateMigratedLinks(t *testing.T) {
settingsConfig := "../../_docker/src/settings/backend/config.yaml"
settings.Initialize(settingsConfig)
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
NonAdminEditable: users.NonAdminEditable{
SidebarLinks: []users.SidebarLink{
{Name: "playwright + files", Category: "source", SourceName: "/app/frontend/tests/playwright-files", Target: "/"},
{Name: "docker", Category: "source", SourceName: "/app/backend", Target: "/"},
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
},
},
},
BackendScopes: []users.BackendScope{
{Path: "../frontend/tests/playwright-files", Scope: "/"},
{Path: ".", Scope: "/"},
{Path: "/tests/playwright-files", Scope: "/"},
},
}
if !updateSidebarLinks(user, false) {
t.Fatal("expected updateSidebarLinks to return true")
}
count := 0
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
count++
}
}
if count != 3 {
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
}
}
func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
},
BackendScopes: []users.BackendScope{
{Path: fixturePlaywrightSource, Scope: "/"},
{Path: fixtureDockerSource, Scope: "/"},
{Path: fixtureAccessSource, Scope: "/"},
},
}
if !updateSidebarLinks(user, false) {
t.Fatal("expected updateSidebarLinks to return true")
}
count := 0
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
count++
}
}
if count != 3 {
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
}
}
func TestUpdateSidebarLinks_keepsDeletedSourceLinkAbsent(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
NonAdminEditable: users.NonAdminEditable{
SidebarLinks: []users.SidebarLink{
{Name: "playwright + files", Category: "source", SourceName: fixturePlaywrightSource, Target: "/"},
{Name: "docker", Category: "source", SourceName: fixtureDockerSource, Target: "/"},
},
},
},
BackendScopes: []users.BackendScope{
{Path: fixturePlaywrightSource, Scope: "/"},
{Path: fixtureDockerSource, Scope: "/"},
{Path: fixtureAccessSource, Scope: "/"},
},
}
if !updateSidebarLinks(user, false) {
t.Fatal("expected default source names to be blanked on normalize")
}
if len(user.SidebarLinks) != 2 {
t.Fatalf("got %d sidebar links, want 2: %v", len(user.SidebarLinks), user.SidebarLinks)
}
for _, link := range user.SidebarLinks {
if link.Name != "" {
t.Fatalf("expected blank default name, got %#v", link)
}
}
}
+67 -64
View File
@@ -4,8 +4,8 @@ import (
"reflect"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/stretchr/testify/assert"
)
@@ -25,6 +25,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
"sourceA": &sourceA,
"sourceB": &sourceB,
}
settings.InitializeUserResolvers()
// ---------------------
// Test Cases Definition
@@ -32,108 +33,110 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
testCases := []struct {
name string
user *users.User
expectedPhase1 []users.SourceScope
expectedPhase2 []users.SourceScope
expectedPhase1 []users.BackendScope
expectedPhase2 []users.BackendScope
}{
{
name: "Single valid scope",
user: &users.User{
Scopes: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
BackendScopes: []users.BackendScope{
{Scope: "/home", Path: "/pathA"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
expectedPhase1: []users.BackendScope{
{Scope: "/home", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
// Phase2: both already present after phase1 merge.
expectedPhase2: []users.BackendScope{
{Scope: "/home", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
{
name: "Single empty scope path",
user: &users.User{
Scopes: []users.SourceScope{
{Scope: "", Name: "/pathB"},
BackendScopes: []users.BackendScope{
{Scope: "", Path: "/pathB"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultB", Name: "/pathB"},
expectedPhase1: []users.BackendScope{
{Scope: "/defaultB", Path: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
{
name: "Two scopes, one includes username in path",
user: &users.User{
Username: "user123",
Scopes: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
FrontendUser: users.FrontendUser{Username: "user123"},
BackendScopes: []users.BackendScope{
{Scope: "/home/user123", Path: "/pathA"},
{Scope: "/data", Path: "/pathB"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
expectedPhase1: []users.BackendScope{
{Scope: "/home/user123", Path: "/pathA"},
{Scope: "/data", Path: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
expectedPhase2: []users.BackendScope{
{Scope: "/home/user123", Path: "/pathA"},
{Scope: "/data", Path: "/pathB"},
},
},
{
name: "Two scopes, one with empty name",
user: &users.User{
Scopes: []users.SourceScope{
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
BackendScopes: []users.BackendScope{
{Scope: "/home", Path: "/pathB"},
{Scope: "/data", Path: "/somethingElse"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
expectedPhase1: []users.BackendScope{
{Scope: "/home", Path: "/pathB"},
{Scope: "/data", Path: "/somethingElse"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/home", Path: "/pathB"},
{Scope: "/data", Path: "/somethingElse"},
},
},
{
name: "No scopes at all",
user: &users.User{
Scopes: []users.SourceScope{},
BackendScopes: []users.BackendScope{},
},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultB", Name: "/pathB"},
expectedPhase1: []users.BackendScope{
{Scope: "/defaultB", Path: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
{
name: "All user Scope and source change",
user: &users.User{
Username: "user123",
Scopes: []users.SourceScope{
{Scope: "/defaultC/user123", Name: "/pathC"},
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
FrontendUser: users.FrontendUser{Username: "user123"},
BackendScopes: []users.BackendScope{
{Scope: "/defaultC/user123", Path: "/pathC"},
{Scope: "/defaultA/user123", Path: "/pathA"},
{Scope: "/defaultB/user123", Path: "/pathB"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
{Scope: "/defaultC/user123", Name: "/pathC"},
// updateUserScopes emits configured sources first (Sources order), then unknown paths.
expectedPhase1: []users.BackendScope{
{Scope: "/defaultA/user123", Path: "/pathA"},
{Scope: "/defaultB/user123", Path: "/pathB"},
{Scope: "/defaultC/user123", Path: "/pathC"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
{Scope: "/defaultC/user123", Name: "/pathC"},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA/user123", Path: "/pathA"},
{Scope: "/defaultB/user123", Path: "/pathB"},
{Scope: "/defaultC/user123", Path: "/pathC"},
},
},
}
@@ -143,10 +146,10 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
// ---------------------
for _, tc := range testCases {
t.Run(tc.name+"_Phase1", func(t *testing.T) {
originalScopes := tc.user.Scopes
originalScopes := tc.user.BackendScopes
updated := updateUserScopes(tc.user)
assert.Equal(t, tc.expectedPhase1, tc.user.Scopes, "Phase1 scope mismatch for test case: %s", tc.name)
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.Scopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.Scopes, tc.expectedPhase1)
assert.Equal(t, tc.expectedPhase1, tc.user.BackendScopes, "Phase1 scope mismatch for test case: %s", tc.name)
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.BackendScopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.BackendScopes, tc.expectedPhase1)
})
}
@@ -171,10 +174,10 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
// Run again without resetting user objects to test idempotency + renaming
for _, tc := range testCases {
t.Run(tc.name+"_Phase2", func(t *testing.T) {
originalScopes := tc.user.Scopes
originalScopes := tc.user.BackendScopes
updated := updateUserScopes(tc.user)
assert.Equal(t, tc.expectedPhase2, tc.user.Scopes, "Phase2 scope mismatch for test case: %s", tc.name)
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.Scopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.Scopes, tc.expectedPhase1)
assert.Equal(t, tc.expectedPhase2, tc.user.BackendScopes, "Phase2 scope mismatch for test case: %s", tc.name)
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.BackendScopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.BackendScopes, tc.expectedPhase1)
})
}
@@ -1,236 +0,0 @@
package settings
import (
"regexp"
"strings"
"testing"
)
func TestGenerateConfigYaml_Basic(t *testing.T) {
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
// Test using the actual source directory structure
trueVal := true
config := &Settings{
UserDefaults: UserDefaults{
Locale: "es", // Non-default value
DarkMode: &trueVal, // Default value
DisableOfficePreviewExt: ".docx .xlsx", // This field is deprecated
},
Auth: Auth{
Key: "secret123", // This is a secret field
AdminUsername: "testadmin", // This is a secret field
AdminPassword: "testpassword", // This is a secret field
},
}
// Test different combinations
testCases := []struct {
name string
showComments bool
showFull bool
filterDeprecated bool
expectSecrets bool
expectDeprecated bool
}{
{
name: "API_mode_all_fields",
showComments: false,
showFull: true,
filterDeprecated: false,
expectSecrets: true, // Secrets should be hidden
expectDeprecated: true, // Deprecated should be shown
},
{
name: "Static_config_filter_deprecated",
showComments: true,
showFull: true,
filterDeprecated: true,
expectSecrets: true, // Secrets should be hidden
expectDeprecated: false, // Deprecated should be filtered
},
{
name: "Filtered_non_defaults",
showComments: false,
showFull: false,
filterDeprecated: false,
expectSecrets: true, // Secrets should be hidden
expectDeprecated: false, // Might not appear if matches default
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Use the current directory structure for real testing
yamlOutput, err := GenerateConfigYaml(config, tc.showComments, tc.showFull, tc.filterDeprecated)
if err != nil {
t.Fatalf("GenerateConfigYaml failed: %v", err)
}
// Basic validation - should produce some output
if len(yamlOutput) == 0 {
t.Fatal("Generated empty YAML")
}
// Test string quoting - string values should be quoted, numbers and booleans should not
lines := strings.Split(yamlOutput, "\n")
for _, line := range lines {
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
// This is a key-value line, skip comment portion
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
value := strings.TrimSpace(parts[1])
// Remove comments
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
value = strings.TrimSpace(value[:commentIdx])
}
// Skip empty values, arrays, and objects
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") {
continue
}
// Check if it's a number (should NOT be quoted)
if reNumber.MatchString(value) {
if strings.HasPrefix(value, "\"") {
t.Errorf("Numeric value should not be quoted in line: %s (value: '%s')", line, value)
}
continue
}
// Check if it's a boolean (should NOT be quoted)
if value == "true" || value == "false" {
if strings.HasPrefix(value, "\"") {
t.Errorf("Boolean value should not be quoted in line: %s (value: '%s')", line, value)
}
continue
}
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
if value == "null" {
continue
}
// Everything else should be a quoted string
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
// Add debug info to see what's wrong
t.Logf("DEBUG: Checking value '%s' from line '%s'", value, strings.TrimSpace(line))
t.Errorf("String value should be quoted in line: %s (value: %s)", strings.TrimSpace(line), value)
}
}
}
}
// Test secret hiding
if tc.expectSecrets {
if !strings.Contains(yamlOutput, "**hidden**") {
t.Error("Expected secrets to be hidden with **hidden**")
}
if strings.Contains(yamlOutput, "secret123") || strings.Contains(yamlOutput, "testadmin") || strings.Contains(yamlOutput, "testpassword") {
t.Error("Secret values should not appear in output")
}
}
// Test deprecated field filtering
hasDeprecated := strings.Contains(yamlOutput, "disableOfficePreviewExt")
if tc.expectDeprecated && !hasDeprecated {
t.Error("Expected deprecated field to be present")
}
if !tc.expectDeprecated && hasDeprecated && tc.filterDeprecated {
t.Error("Expected deprecated field to be filtered out when filterDeprecated=true")
}
// Test comments
hasComments := strings.Contains(yamlOutput, "#")
if tc.showComments && !hasComments {
t.Error("Expected comments to be present when showComments=true")
}
t.Logf("Test case: %s\nGenerated YAML:\n%s\n", tc.name, yamlOutput)
})
}
}
func TestCollectCommentsAndSecrets_Basic(t *testing.T) {
// Test the comment and secret collection directly
comments, secrets, deprecated, err := CollectCommentsAndSecrets(".")
if err != nil {
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
}
// Should find some secrets in the actual source files
foundSecrets := false
for typeName, typeSecrets := range secrets {
for fieldName := range typeSecrets {
t.Logf("Found secret: %s.%s", typeName, fieldName)
foundSecrets = true
}
}
if !foundSecrets {
t.Error("Expected to find some secret fields in the actual source code")
}
// Should find some deprecated fields
foundDeprecated := false
for typeName, typeDeprecated := range deprecated {
for fieldName := range typeDeprecated {
t.Logf("Found deprecated: %s.%s", typeName, fieldName)
foundDeprecated = true
}
}
if !foundDeprecated {
t.Error("Expected to find some deprecated fields in the actual source code")
}
t.Logf("Comments: %d types", len(comments))
t.Logf("Secrets: %d types", len(secrets))
t.Logf("Deprecated: %d types", len(deprecated))
}
func TestGenerateYaml_StaticGeneration(t *testing.T) {
// Test the static generation function that's used by FILEBROWSER_GENERATE_CONFIG=true
// Create a temporary config for testing
config := &Settings{
UserDefaults: UserDefaults{
Locale: "en",
DisableOfficePreviewExt: ".docx .xlsx", // This should be filtered out
},
Auth: Auth{
Key: "test-secret", // This should be redacted
},
}
// Test static generation with comments and deprecated filtering
yamlOutput, err := GenerateConfigYamlWithSource(config, true, true, true, ".")
if err != nil {
t.Fatalf("Static generation failed: %v", err)
}
// Verify comments are included
if !strings.Contains(yamlOutput, "#") {
t.Error("Static generation should include comments")
}
// Verify deprecated field is filtered out
if strings.Contains(yamlOutput, "disableOfficePreviewExt") {
t.Error("Static generation should filter out deprecated field disableOfficePreviewExt")
}
// Verify secrets are redacted
if !strings.Contains(yamlOutput, "**hidden**") {
t.Error("Static generation should redact secrets")
}
if strings.Contains(yamlOutput, "test-secret") {
t.Error("Static generation should not contain actual secret values")
}
// Verify it has proper structure
if !strings.Contains(yamlOutput, "userDefaults:") {
t.Error("Static generation should contain userDefaults section")
}
if !strings.Contains(yamlOutput, "auth:") {
t.Error("Static generation should contain auth section")
}
t.Logf("Static generation successful with comments and proper filtering")
}
-125
View File
@@ -1,125 +0,0 @@
//go:generate go run ./tools/yaml.go -input=common/settings/settings.go -output=config.generated.yaml
package settings
import (
"crypto/rand"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// boolPtr returns a pointer to a bool value
func boolPtr(b bool) *bool {
return &b
}
// boolValueOrDefault returns the value of a bool pointer, or the default if nil
func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
if ptr != nil {
return *ptr
}
return defaultValue
}
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
func ConvertPermissionsToUsers(p UserDefaultsPermissions) users.Permissions {
return users.Permissions{
Api: p.Api,
Admin: p.Admin,
Modify: p.Modify,
Share: p.Share,
Realtime: p.Realtime,
Delete: p.Delete,
Create: p.Create,
Download: boolValueOrDefault(p.Download, true),
}
}
const DefaultUsersHomeBasePath = "/users"
// AuthMethod describes an authentication method.
type AuthMethod string
// GenerateKey generates a key of 512 bits.
func GenerateKey() ([]byte, error) {
b := make([]byte, 64)
_, err := rand.Read(b)
// Note that err == nil only if we read len(b) bytes.
if err != nil {
return nil, err
}
return b, nil
}
func GetSettingsConfig(nameType string, Value string) string {
return nameType + Value
}
func AdminPerms() users.Permissions {
return users.Permissions{
Modify: true,
Share: true,
Admin: true,
Api: true,
}
}
// Apply applies the default options to a user.
func ApplyUserDefaults(u *users.User) {
u.StickySidebar = Config.UserDefaults.StickySidebar
u.DisableSettings = Config.UserDefaults.DisableSettings
u.HideFilesInTree = Config.UserDefaults.HideFilesInTree
u.DeleteAfterArchive = Config.UserDefaults.DeleteAfterArchive
// Handle DarkMode with default - dereference pointer from config
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
// Handle Permissions - convert from pointer-based defaults to regular bools
u.Permissions.Api = Config.UserDefaults.Permissions.Api
u.Permissions.Admin = Config.UserDefaults.Permissions.Admin
u.Permissions.Modify = Config.UserDefaults.Permissions.Modify
u.Permissions.Share = Config.UserDefaults.Permissions.Share
u.Permissions.Realtime = Config.UserDefaults.Permissions.Realtime
u.Permissions.Delete = Config.UserDefaults.Permissions.Delete
u.Permissions.Create = Config.UserDefaults.Permissions.Create
u.Permissions.Download = boolValueOrDefault(Config.UserDefaults.Permissions.Download, true)
// Handle Preview - convert from pointer-based defaults to regular bools
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
u.Preview.Audio = boolValueOrDefault(Config.UserDefaults.Preview.Audio, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
u.Preview.Models = boolValueOrDefault(Config.UserDefaults.Preview.Models, true)
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
u.ThemeColor = Config.UserDefaults.ThemeColor
u.GallerySize = Config.UserDefaults.GallerySize
u.QuickDownload = Config.UserDefaults.QuickDownload
u.LockPassword = Config.UserDefaults.LockPassword
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.FileLoading = Config.UserDefaults.FileLoading
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
u.PreferEditorForMarkdown = Config.UserDefaults.PreferEditorForMarkdown
if len(u.Scopes) == 0 && u.Username != "anonymous" {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
u.Scopes = append(u.Scopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: source.Config.DefaultUserScope,
})
}
}
}
}
-379
View File
@@ -1,379 +0,0 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type AllowedMethods string
var Env Environment
const (
ProxyAuth AllowedMethods = "proxyAuth"
NoAuth AllowedMethods = "noAuth"
PasswordAuth AllowedMethods = "passwordAuth"
)
type Settings struct {
Server Server `json:"server"`
Auth Auth `json:"auth"`
Frontend Frontend `json:"frontend"`
UserDefaults UserDefaults `json:"userDefaults"`
Integrations Integrations `json:"integrations"`
}
type Environment struct {
IsPlaywright bool `json:"-"`
IsDevMode bool `json:"-"`
IsFirstLoad bool `json:"-"` // used internally to track if this is the first load of the application
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
FFmpegPath string `json:"-"`
FFprobePath string `json:"-"`
LoginIconPath string `json:"-"` // resolved login icon path (filesystem or embedded)
LoginIconIsCustom bool `json:"-"` // true if login icon is from custom filesystem path
LoginIconEmbeddedPath string `json:"-"` // embedded asset path for default icon
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
PWAIconsDir string `json:"-"` // directory where generated PWA icons are stored
PWAIcon192 string `json:"-"` // path to 192x192 PWA icon
PWAIcon256 string `json:"-"` // path to 256x256 PWA icon
PWAIcon512 string `json:"-"` // path to 512x512 PWA icon
}
type Server struct {
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Port int `json:"port"` // port to listen on
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging" yaml:"logging"`
Database string `json:"database"` // path to the database file
Sources []*Source `json:"sources" validate:"required,dive"`
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
// not exposed to config
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
}
type Filesystem struct {
CreateFilePermission string `json:"createFilePermission" validate:"required,file_permission"` // Unix permissions like 644, 755, 2755 (default: 644)
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
}
// Index SQL startup integrity modes (IndexSqlConfig.StartupIntegrityCheck).
const (
// IndexStartupIntegrityQuickCheck runs PRAGMA quick_check (default). Slower on very large DBs but thorough.
IndexStartupIntegrityQuickCheck = "quickCheck"
// IndexStartupIntegrityProbe verifies sqlite_master and optionally one row read only; fast for large indexes.
IndexStartupIntegrityProbe = "probe"
// IndexStartupIntegrityOff skips startup checks beyond sql.Open Ping; least safe, fastest boot.
IndexStartupIntegrityOff = "off"
)
type IndexSqlConfig struct {
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
StartupIntegrityCheck string `json:"startupIntegrityCheck,omitempty" validate:"omitempty,oneof=quickCheck probe off"` // the method used to check the integrity of the index database on startup (default: quickCheck)
}
type Integrations struct {
OnlyOffice OnlyOffice `json:"office" validate:"omitempty"`
Media Media `json:"media" validate:"omitempty"`
}
// onlyoffice secret is stored in the local.json file
// docker exec <containerID> /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string'
type OnlyOffice struct {
Url string `json:"url" validate:"required"` // The URL to the OnlyOffice Document Server, needs to be accessible to the user.
InternalUrl string `json:"internalUrl"` // An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
Secret string `json:"secret" validate:"required"` // secret: authentication key for OnlyOffice integration
ViewOnly bool `json:"viewOnly"` // view only mode for OnlyOffice
}
type Media struct {
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // extract embedded subtitles from media files
ExiftoolPath string `json:"exiftoolPath"` // path to exiftool executable
}
type FfmpegConvert struct {
ImagePreview map[ImagePreviewType]*bool `json:"imagePreview"` // supported image preview formats. defaults vary by type (see individual type docs)
VideoPreview map[VideoPreviewType]*bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
}
type ImagePreviewType string
const (
HEICImagePreview ImagePreviewType = "heic"
JPEGImagePreview ImagePreviewType = "jpeg" // only used as fallback for JPEG formats that can't otherwise be decoded
//RAWImagePreview ImagePreviewType = "raw"
)
func (i ImagePreviewType) String() string {
return string(i)
}
// AllImagePreviewTypes contains all supported image preview types.
var AllImagePreviewTypes = []ImagePreviewType{
HEICImagePreview,
JPEGImagePreview,
//RAWImagePreview,
}
type VideoPreviewType string
const (
MP4VideoPreview VideoPreviewType = "mp4"
WebMVideoPreview VideoPreviewType = "webm"
MOVVideoPreview VideoPreviewType = "mov"
AVIVideoPreview VideoPreviewType = "avi"
MKVVideoPreview VideoPreviewType = "mkv"
FLVVideoPreview VideoPreviewType = "flv"
WMVVideoPreview VideoPreviewType = "wmv"
M4VVideoPreview VideoPreviewType = "m4v"
ThreeGPVideoPreview VideoPreviewType = "3gp"
ThreeGP2VideoPreview VideoPreviewType = "3g2"
TSVideoPreview VideoPreviewType = "ts"
M2TSVideoPreview VideoPreviewType = "m2ts"
VOBVideoPreview VideoPreviewType = "vob"
ASFVideoPreview VideoPreviewType = "asf"
MPGVideoPreview VideoPreviewType = "mpg"
MPEGVideoPreview VideoPreviewType = "mpeg"
F4VVideoPreview VideoPreviewType = "f4v"
OGVVideoPreview VideoPreviewType = "ogv"
)
func (v VideoPreviewType) String() string {
return string(v)
}
// AllVideoPreviewTypes contains all supported video preview types.
var AllVideoPreviewTypes = []VideoPreviewType{
MP4VideoPreview,
WebMVideoPreview,
MOVVideoPreview,
AVIVideoPreview,
MKVVideoPreview,
FLVVideoPreview,
WMVVideoPreview,
M4VVideoPreview,
ThreeGPVideoPreview,
ThreeGP2VideoPreview,
TSVideoPreview,
M2TSVideoPreview,
VOBVideoPreview,
ASFVideoPreview,
MPGVideoPreview,
MPEGVideoPreview,
F4VVideoPreview,
OGVVideoPreview,
}
type LogConfig struct {
Levels string `json:"levels" yaml:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels" yaml:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output" yaml:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors" yaml:"noColors"` // disable colors in the output
Json bool `json:"json" yaml:"json"` // output in json format
Utc bool `json:"utc" yaml:"utc"` // use UTC time in the output instead of local time
}
type Source struct {
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
Name string `json:"name"` // display name
Config SourceConfig `json:"config,omitempty"`
}
type SourceConfig struct {
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // deprecated: create a rule with indexingIntervalMinutes to set the indexing interval for this source
DisableIndexing bool `json:"disableIndexing,omitempty"` // deprecated: use indexingDisabled instead to disable the indexing of this source
Conditionals ConditionalFilter `json:"conditionals"` // deprecated: use source.rules instead
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // deprecated: create a user directory for each user under defaultUserScope + username (always true now)
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
// hidden but used internally - optimized map lookups for conditional rules
ResolvedRules ResolvedRulesConfig `json:"-"`
}
type ConditionalFilter struct {
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
IgnoreHidden bool `json:"ignoreHidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // deprecated: use ignoreZeroSizeFolders instead. eg, FolderPath: "/" and ignoreZeroSizeFolders: true will ignore folders with 0 size under the root folder.
ItemRules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
}
type ConditionalRule struct {
NeverWatchPath string `json:"neverWatchPath"` // index the folder in the first pass to get included in search, but never re-indexed.
IncludeRootItem string `json:"includeRootItem"` // include only these items at root folder level
FileStartsWith string `json:"fileStartsWith"` // (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
FolderStartsWith string `json:"folderStartsWith"` // (global) exclude folders that start with these prefixes. Eg. "archive-" or "backup-"
FileEndsWith string `json:"fileEndsWith"` // (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
FolderEndsWith string `json:"folderEndsWith"` // (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
FolderPath string `json:"folderPath"` // (global) exclude folders that match this path. Eg. "/path/to/folder" or "/path/to/folder/subfolder"
FilePath string `json:"filePath"` // (global) exclude files that match this path. Eg. "/path/to/file.txt" or "/path/to/file.txt/subfile.txt"
FileNames string `json:"fileNames"` // deprecated: exclude files that match these names. Eg. "file.txt" or "test.csv"
FolderNames string `json:"folderNames"` // deprecated: exclude folders that match these names. Eg. "folder" or "subfolder"
FileName string `json:"fileName"` // (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
FolderName string `json:"folderName"` // (global) exclude folders that match these names. Eg. "folder" or "subfolder"
Viewable bool `json:"viewable"` // Enable viewing in UI but exclude from indexing
IgnoreHidden bool `json:"ignoreHidden"` // Excludes only hidden files and folders
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // Excludes only folders with 0 size
IgnoreSymlinks bool `json:"ignoreSymlinks"` // Excludes symbolic links
}
// ConditionalMaps provides O(1) lookup performance for conditional rules
// Maps are built from ConditionalFilter during initialization
type ResolvedRulesConfig struct {
// Exact match maps - O(1) lookup (only for names, not StartsWith/EndsWith)
FileNames map[string]ConditionalRule // key: file name
FolderNames map[string]ConditionalRule // key: folder name
// exact match for paths
FilePaths map[string]ConditionalRule // key: file path
FolderPaths map[string]ConditionalRule // key: folder path
FileEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
FolderEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
FileStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
FolderStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
// NeverWatch paths map - O(1) lookup for all paths with neverWatch: true
// This replaces the old NeverWatchPaths slice
NeverWatchPaths map[string]struct{} // key: full folder path
IncludeRootItems map[string]struct{} // key: inclusive root item
IgnoreAllHidden bool // Excludes all hidden files and folders
IgnoreAllZeroSizeFolders bool // Excludes all folders with 0 size
IgnoreAllSymlinks bool // Excludes all symbolic links
IndexingDisabled bool // Excludes all files and folders from indexing
NoRules bool // No rules are configured
}
type Frontend struct {
Name string `json:"name"` // display name
DisableDefaultLinks bool `json:"disableDefaultLinks"` // disable default links in the sidebar
DisableUsedPercentage bool `json:"disableUsedPercentage"` // disable used percentage for the sources in the sidebar
ExternalLinks []ExternalLink `json:"externalLinks"`
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
Styling StylingConfig `json:"styling"`
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
LoginButtonText string `json:"loginButtonText"` // text to display on the login button
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
}
type StylingConfig struct {
DisableEventBasedThemes bool `json:"disableEventThemes"` // disable the event based themes,
CustomCSS string `json:"customCSS"` // if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
CustomCSSRaw string `json:"-"` // The css raw content to use for the custom css.
LightBackground string `json:"lightBackground"` // specify a valid CSS color property value to use as the background color in light mode
DarkBackground string `json:"darkBackground"` // Specify a valid CSS color property value to use as the background color in dark mode
CustomThemes map[string]CustomTheme `json:"customThemes"` // A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
// In-memory (not exposed to config)
CustomThemeOptions map[string]CustomTheme `json:"-"` // not exposed
}
type CustomTheme struct {
Description string `json:"description"` // The description of the theme to display in the UI.
CSS string `json:"css,omitempty"` // The css file path and filename to use for the theme.
CssRaw string `json:"-"` // The css raw content to use for the theme.
}
type ExternalLink struct {
Text string `json:"text" validate:"required"` // the text to display on the link
Title string `json:"title"` // the title to display on hover
Url string `json:"url" validate:"required"` // the url to link to
}
// UserDefaultsPreview holds preview settings with pointer types for defaults
type UserDefaultsPreview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
HighQuality *bool `json:"highQuality"` // deprecated: always true in v1.3.0+
Image *bool `json:"image"` // show thumbnails for image files
Video *bool `json:"video"` // show thumbnails for video files
Audio *bool `json:"audio"` // show thumbnails for audio files
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office *bool `json:"office"` // show thumbnails for office files
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
Models *bool `json:"models"` // show live thumbnails for 3D models files
}
// UserDefaultsPermissions holds permission settings with pointer types for defaults
type UserDefaultsPermissions struct {
Api bool `json:"api"` // allow api access
Admin bool `json:"admin"` // allow admin access
Modify bool `json:"modify"` // allow modifying files
Share bool `json:"share"` // allow sharing files
Realtime bool `json:"realtime"` // allow realtime updates
Delete bool `json:"delete"` // allow deleting files
Create bool `json:"create"` // allow creating or uploading files
Download *bool `json:"download"` // allow downloading files
}
// UserDefaults is a type that holds the default values
// for some fields on User.
type UserDefaults 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
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
Preview UserDefaultsPreview `json:"preview"`
DefaultScopes []users.SourceScope `json:"-"`
Permissions UserDefaultsPermissions `json:"permissions"`
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer.
}
-35
View File
@@ -1,35 +0,0 @@
server:
numImageProcessors: 4
socket: ""
tlsKey: ""
tlsCert: ""
disablePreviews: false
disablePreviewResize: false
port: 80
baseURL: "/"
database: "mydb.db"
sources:
- path: "."
integrations:
office:
url: "http://localhost:9052"
secret: 6H1H7GzUk2alRkNxAPav0W02g8JS5oUd
auth:
methods:
password:
enabled: true
frontend:
name: ""
disableUsedPercentage: true
userDefaults:
locale: ""
viewMode: ""
darkMode: true
disableSettings: false
singleClick: true
permissions:
admin: true
modify: true
share: true
api: true
dateFormat: false
-46
View File
@@ -1,46 +0,0 @@
package utils
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"golang.org/x/crypto/bcrypt"
)
var BcryptCost = bcrypt.DefaultCost
var InvalidPasswordHash = ""
// HashPwd hashes a password.
func HashPwd(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
return string(bytes), err
}
// CheckPwd checks if a password is correct.
func CheckPwd(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
func SetInvalidPasswordHash() error {
passwordHash, err := HashPwd(InsecureRandomIdentifier(16))
if err != nil {
return err
}
InvalidPasswordHash = passwordHash
return nil
}
func HashSHA256(data string) string {
bytes := sha256.Sum256([]byte(data))
return hex.EncodeToString(bytes[:])
}
func GenerateKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return ""
}
return string(b)
}
-46
View File
@@ -1,46 +0,0 @@
package utils
import (
"time"
"math/rand"
)
func GenerateRandomPath(levels int) string {
rand.New(rand.NewSource(time.Now().UnixNano()))
dirName := "srv"
for i := 0; i < levels; i++ {
dirName += "/" + GetRandomTerm()
}
return dirName
}
func GetRandomTerm() string {
wordbank := []string{
"hi", "test", "other", "name",
"cool", "things", "more", "items",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func GetRandomExtension() string {
wordbank := []string{
".txt", ".mp3", ".mov", ".doc",
".mp4", ".bak", ".zip", ".jpg",
}
rand.New(rand.NewSource(time.Now().UnixNano()))
index := rand.Intn(len(wordbank))
return wordbank[index]
}
func GenerateRandomSearchTerms(numTerms int) []string {
// Generate random search terms
searchTerms := make([]string, numTerms)
for i := 0; i < numTerms; i++ {
searchTerms[i] = GetRandomTerm()
}
return searchTerms
}
-11
View File
@@ -1,11 +0,0 @@
server:
logging:
- levels: "info|warning|error"
sources:
- path: "/srv"
userDefaults:
preview:
image: true
popup: true
video: true
audio: true
@@ -0,0 +1,23 @@
http:
port: 8080
baseURL: "/"
server:
logging:
- levels: "info|warning|error"
apiFilter: "disabled"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: true
create: true
auth:
methods:
password:
enabled: true
minLength: 5
signup: false
+64
View File
@@ -0,0 +1,64 @@
http:
port: 8080
baseURL: "/"
server:
database: "playwright-files.db"
logging:
- levels: "info|warning|debug|error"
sources:
- path: "../frontend/playwright-files"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: false
create: false
delete: false
userDefaults:
fileViewer:
disableOnlyOfficeExt: ".md .txt .pdf"
disableViewingExt: ""
sidebar:
hideFileActions: false
disableQuickToggles: false
sticky: true
disableHideOnPreview: true
search:
disableOptions: false
ui:
darkMode: true
themeColor: "var(--blue)"
locale: ""
listing:
singleClick: false
showHidden: false
dateFormat: false
viewMode: "normal"
gallerySize: 3
quickDownload: false
showSelectMultiple: false
deleteWithoutConfirming: false
preview:
image: true
video: true
motionVideoPreview: true
office: true
popup: true
folder: true
disablePreviewExt: ""
account:
lockPassword: false
disableSettings: false
disableUpdateNotifications: false
showAdvancedProfile: false
permissions:
api: false
admin: false
share: false
realtime: false
#office:
# url: "http://localhost:9052"
# secret: "6H1H7GzUk2alRkNxAPav0W02g8JS5oUd"
+12 -50
View File
@@ -1,55 +1,17 @@
server:
http:
port: 80
baseURL: "/"
server:
logging:
- levels: "info|warning|error"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "/srv"
userDefaults:
editorQuickSave: false # show quick save button in editor
hideSidebarFileActions: false # hide the file actions in the sidebar
disableQuickToggles: false # disable the quick toggles in the sidebar
disableSearchOptions: false # disable the search options in the search bar
stickySidebar: true # keep sidebar open when navigating
hideFilesInTree: false # hide files in the sidebar tree navigation, when true, will show only directories.
darkMode: true # should dark mode be enabled (defaults to true if not set)
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
singleClick: false # open directory on single click, also enables middle click to open in new tab
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
quickDownload: false # show icon to download in one click
disablePreviewExt: "" # space separated list of file extensions to disable preview for
disableViewingExt: "" # space separated list of file extensions to disable viewing for
lockPassword: false # disable the user from changing their password
disableSettings: false # disable the user from viewing the settings page
preview:
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
image: true # show thumbnail preview image for image files (defaults to true if not set)
video: true # show thumbnail preview image for video files (defaults to true if not set)
audio: true # show thumbnail preview image for audio files (defaults to true if not set)
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
office: true # show thumbnail preview image for office files (defaults to true if not set)
popup: true # show larger popup preview when hovering over thumbnail (defaults to true if not set)
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
defaultMediaPlayer: false # disable html5 media player and use the default media player
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
permissions:
api: false # allow api access
admin: false # allow admin access
modify: false # allow modifying files
share: false # allow sharing files
realtime: false # allow realtime updates
delete: false # allow deleting files
create: false # allow creating or uploading files
download: true # allow downloading files (defaults to true if not set)
disableUpdateNotifications: false # disable update notifications banner for admin users
deleteWithoutConfirming: false # delete files without confirmation
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
customTheme: "" # Name of theme to use chosen from custom themes config.
showSelectMultiple: false # show select multiple files on desktop
debugOffice: false # debug onlyoffice editor
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
auth:
methods:
password:
enabled: true
minLength: 5
signup: false
passkey:
enabled: false
File diff suppressed because it is too large. Load diff
-75
View File
@@ -1,75 +0,0 @@
package share
import (
"sync"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type CommonShare struct {
DownloadsLimit int `json:"downloadsLimit,omitempty"`
ShareTheme string `json:"shareTheme,omitempty"`
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
MaxBandwidth int `json:"maxBandwidth,omitempty"`
DisableThumbnails bool `json:"disableThumbnails,omitempty"`
KeepAfterExpiration bool `json:"keepAfterExpiration,omitempty"`
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
ThemeColor string `json:"themeColor,omitempty"`
Banner string `json:"banner,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Favicon string `json:"favicon,omitempty"`
QuickDownload bool `json:"quickDownload,omitempty"`
HideNavButtons bool `json:"hideNavButtons,omitempty"`
DisableSidebar bool `json:"disableSidebar"`
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
Path string `json:"path,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
ShareURL string `json:"shareURL,omitempty"`
FaviconUrl string `json:"faviconUrl,omitempty"`
BannerUrl string `json:"bannerUrl,omitempty"`
DisableShareCard bool `json:"disableShareCard,omitempty"`
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
EnableOnlyOffice bool `json:"enableOnlyOffice,omitempty"`
ShareType string `json:"shareType"` // type of share: normal, upload, max
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"` // can be io intensive for large files and take 10-30 seconds.
AllowDelete bool `json:"allowDelete,omitempty"`
AllowCreate bool `json:"allowCreate,omitempty"` // allow creating files
AllowModify bool `json:"allowModify,omitempty"` // allow modifying files
DisableFileViewer bool `json:"disableFileViewer,omitempty"` // don't allow viewing files
DisableDownload bool `json:"disableDownload,omitempty"` // don't allow downloading files
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HasPassword bool `json:"hasPassword,omitempty"`
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
SourceURL string `json:"sourceURL,omitempty"`
CanEditShare bool `json:"canEditShare,omitempty"`
}
type CreateBody struct {
CommonShare
Hash string `json:"hash,omitempty"`
Password string `json:"password"`
Expires string `json:"expires"`
Unit string `json:"unit"`
}
// Link is the information needed to build a shareable link.
type Link struct {
CommonShare
Downloads int `json:"downloads"`
Hash string `json:"hash" storm:"id,index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
// Token is a random value that will only be set when PasswordHash is set. It is
// URL-Safe and is used to download links in password-protected shares via a
// query arg.
Token string `json:"token,omitempty"`
Mu sync.Mutex `json:"-"`
UserDownloads map[string]int `json:"userDownloads,omitempty"` // Track downloads per username
Version int `json:"version,omitempty"`
}
-409
View File
@@ -1,409 +0,0 @@
package share
import (
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// StorageBackend is the interface to implement for a share storage.
type StorageBackend interface {
All() ([]*Link, error)
FindByUserID(id uint) ([]*Link, error)
GetByHash(hash string) (*Link, error)
GetCommonShareByHash(hash string) (*CommonShare, error)
GetPermanent(path, source string, id uint) (*Link, error)
GetBySourcePath(path, source string) ([]*Link, error)
Gets(path, source string, id uint) ([]*Link, error)
Save(s *Link) error
Delete(hash string) error
}
// crudBackend implements crud.CrudBackend[Link] for share storage.
type crudBackend struct {
back StorageBackend
}
func (c *crudBackend) GetByID(id any) (*Link, error) {
hash, ok := id.(string)
if !ok {
return nil, errors.ErrInvalidDataType
}
return c.back.GetByHash(hash)
}
func (c *crudBackend) GetAll() ([]*Link, error) {
return c.back.All()
}
func (c *crudBackend) Save(obj *Link) error {
return c.back.Save(obj)
}
func (c *crudBackend) DeleteByID(id any) error {
hash, ok := id.(string)
if !ok {
return errors.ErrInvalidDataType
}
return c.back.Delete(hash)
}
// Storage is a share storage using generics.
type Storage struct {
Generic *crud.Storage[Link]
back StorageBackend
shareByHash map[string]*Link // key: link hash
shareByPath map[string]map[string]string // key: pathKey(Source, Path), value: set of hashes (hash -> "")
mu sync.RWMutex
users *users.Storage
}
// pathKey returns the cache key for shareByPath (source + path).
func pathKey(source, path string) string {
return source + ":" + path
}
// setCacheLocked updates both shareByHash and shareByPath for the given link.
// Caller must hold s.mu.
func (s *Storage) setCacheLocked(link *Link) {
if link == nil {
return
}
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
s.shareByHash[link.Hash] = link
key := pathKey(adjustedSource, adjustedPath)
if s.shareByPath[key] == nil {
s.shareByPath[key] = make(map[string]string)
}
s.shareByPath[key][link.Hash] = ""
}
// setCache updates both caches for the given link. It acquires s.mu.
func (s *Storage) setCache(link *Link) {
s.mu.Lock()
s.setCacheLocked(link)
s.mu.Unlock()
}
// deleteFromCacheLocked removes the link (by hash) from both caches.
// Caller must hold s.mu.
func (s *Storage) deleteFromCacheLocked(hash string) {
link, ok := s.shareByHash[hash]
if !ok {
return
}
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
key := pathKey(adjustedSource, adjustedPath)
if inner, ok := s.shareByPath[key]; ok {
delete(inner, hash)
if len(inner) == 0 {
delete(s.shareByPath, key)
}
}
delete(s.shareByHash, hash)
}
// deleteFromCache removes the link (by hash) from both caches. It acquires s.mu.
func (s *Storage) deleteFromCache(hash string) {
s.mu.Lock()
s.deleteFromCacheLocked(hash)
s.mu.Unlock()
}
// setCacheAfterMove updates both caches when a link's Source or Path changed. It acquires s.mu.
func (s *Storage) setCacheAfterMove(link *Link, oldSource, oldPath string) {
s.mu.Lock()
if link == nil {
return
}
adjustedOldPath := utils.AddTrailingSlashIfNotExists(oldPath)
adjustedOldSource := utils.AddTrailingSlashIfNotExists(oldSource)
oldKey := pathKey(adjustedOldSource, adjustedOldPath)
if inner, ok := s.shareByPath[oldKey]; ok {
delete(inner, link.Hash)
if len(inner) == 0 {
delete(s.shareByPath, oldKey)
}
}
s.setCacheLocked(link)
s.mu.Unlock()
}
// NewStorage creates a share links storage from a backend and populates the
// in-memory cache from the database so all reads can be served from cache.
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
s := &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareByHash: make(map[string]*Link),
shareByPath: make(map[string]map[string]string),
users: usersStore,
}
s.loadShareCache()
return s
}
// loadShareCache fills shareByHash and shareByPath from the backend and removes expired links from the DB.
// Call once at startup so all reads can be served from cache.
func (s *Storage) loadShareCache() {
links, err := s.back.All()
if err != nil && err != errors.ErrNotExist {
return
}
if links == nil {
links = []*Link{}
}
s.mu.Lock()
for _, link := range links {
if link == nil {
continue
}
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
continue
}
link.InitUserDownloads()
s.setCacheLocked(link)
}
s.mu.Unlock()
}
// LoadShareCacheFromDB repopulates the in-memory cache from the database.
// Call at startup (e.g. from InitializeDb) to ensure shares are loaded after the store is ready.
func (s *Storage) LoadShareCacheFromDB() {
s.loadShareCache()
}
// All returns all non-expired shares from the cache (populated at startup and by writes).
func (s *Storage) All() ([]*Link, error) {
s.mu.Lock()
result := make([]*Link, 0, len(s.shareByHash))
for _, l := range s.shareByHash {
if l == nil {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
}
// FindByUserID returns non-expired shares for the user from the cache.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
s.mu.Lock()
result := make([]*Link, 0)
for _, l := range s.shareByHash {
if l == nil || l.UserID != id {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
}
// GetByHash wraps StorageBackend.GetByHash and handles expiry.
func (s *Storage) GetByHash(hash string) (*Link, error) {
// return stable in-memory pointer if available
s.mu.RLock()
if link, ok := s.shareByHash[hash]; ok && link != nil {
s.mu.RUnlock()
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
s.deleteFromCache(hash)
return nil, errors.ErrNotExist
}
return link, nil
}
s.mu.RUnlock()
link, err := s.back.GetByHash(hash)
if err != nil {
return nil, err
}
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
return nil, errors.ErrNotExist
}
// Initialize UserDownloads map
link.InitUserDownloads()
s.setCache(link)
return link, nil
}
// GetPermanent wraps StorageBackend.GetPermanent
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
l, err := s.back.GetPermanent(path, source, id)
if err == nil && l != nil {
s.setCache(l)
}
return l, err
}
// Gets returns shares for the given path, source, and user from the cache.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
s.mu.Lock()
adjustedPath := utils.AddTrailingSlashIfNotExists(sourcePath)
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
key := pathKey(adjustedSource, adjustedPath)
hashes := s.shareByPath[key]
result := make([]*Link, 0, len(hashes))
for h := range hashes {
l := s.shareByHash[h]
if l == nil || l.UserID != id {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
}
// GetBySourcePath returns shares for the given path and source from the cache.
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
s.mu.Lock()
adjustedPath := utils.AddTrailingSlashIfNotExists(path)
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
key := pathKey(adjustedSource, adjustedPath)
hashes := s.shareByPath[key]
result := make([]*Link, 0, len(hashes))
for h := range hashes {
l := s.shareByHash[h]
if l == nil {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
}
// IsShared returns whether the given path and source have any shares in the cache.
func (s *Storage) IsShared(path, source string, id uint) bool {
links, _ := s.GetBySourcePath(path, source)
for _, l := range links {
if l.UserID == id {
return true
}
}
return len(links) > 0
}
// UpdateShares updates all shares that match oldSource and oldPath to point to newSource and newPath.
// Handles both exact matches and subdirectories, regardless of trailing slashes.
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
links, err := s.All()
if err != nil && err != errors.ErrNotExist {
logger.Error("failed to list shares", "error", err)
return 0, err
}
// Normalize paths for comparison (remove trailing slashes)
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
newPath = utils.AddTrailingSlashIfNotExists(newPath)
updated := 0
for _, l := range links {
if l == nil || l.Source != oldSource {
continue
}
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
pos := strings.Index(l.Path, oldPath)
if pos < 0 {
continue
}
l.Source = newSource
l.Path = newPath
if err := s.back.Save(l); err != nil {
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
return updated, err
}
s.setCacheAfterMove(l, oldSource, oldPath)
updated++
}
return updated, nil
}
// UpdateSharePath updates the path for a specific share identified by hash
func (s *Storage) UpdateSharePath(hash, newPath string) error {
link, err := s.GetByHash(hash)
if err != nil {
return err
}
oldPath := link.Path
link.Path = newPath
if err := s.back.Save(link); err != nil {
logger.Error("failed to save updated share", "hash", hash, "error", err)
return err
}
s.setCacheAfterMove(link, link.Source, oldPath)
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
return nil
}
// Save wraps StorageBackend.Save
func (s *Storage) Save(l *Link) error {
if err := s.back.Save(l); err != nil {
return err
}
s.setCache(l)
return nil
}
// Delete wraps StorageBackend.Delete
func (s *Storage) Delete(hash string) error {
if err := s.back.Delete(hash); err != nil {
return err
}
s.deleteFromCache(hash)
return nil
}
// Flush is a no-op: every share write (Save, Delete, UpdateShares, UpdateSharePath) already
// updates the database, so the cache and DB stay in sync without flushing.
func (s *Storage) Flush() error {
return nil
}
// GetCommonShareByHash retrieves only the CommonShare attributes for a given hash
func (s *Storage) GetCommonShareByHash(hash string) (*CommonShare, error) {
return s.back.GetCommonShareByHash(hash)
}
-30
View File
@@ -1,30 +0,0 @@
package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
type authBackend struct {
db *storm.DB
}
func (s authBackend) Get(t string) (auth.Auther, error) {
var auther auth.Auther
switch t {
case "password":
auther = &auth.JSONAuth{}
case "proxy":
auther = &auth.ProxyAuth{}
case "noauth":
auther = &auth.NoAuth{}
default:
return nil, errors.ErrInvalidAuthMethod
}
return auther, get(s.db, "auther", auther)
}
func (s authBackend) Save(a auth.Auther) error {
return Save(s.db, "auther", a)
}
-40
View File
@@ -1,40 +0,0 @@
package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// Storage is a storage powered by a Backend which makes the necessary
// verifications when fetching and saving data to ensure consistency.
type BoltStore struct {
Users *users.Storage
Share *share.Storage
Auth *auth.Storage
Settings *settings.Storage
Access *access.Storage
Indexing *dbindex.Storage
}
// NewStorage creates a storage.Storage based on Bolt DB.
func NewStorage(db *storm.DB) (*BoltStore, error) {
userStore := users.NewStorage(usersBackend{db: db})
authStore, err := auth.NewStorage(authBackend{db: db}, userStore)
if err != nil {
return nil, err
}
return &BoltStore{
Users: userStore,
Share: share.NewStorage(shareBackend{db: db}, userStore),
Auth: authStore,
Settings: settings.NewStorage(settingsBackend{db: db}),
Access: access.NewStorage(db, userStore),
Indexing: dbindex.NewStorage(indexingBackend{db: db}),
}, nil
}
-31
View File
@@ -1,31 +0,0 @@
package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
type settingsBackend struct {
db *storm.DB
}
func (s settingsBackend) Get() (*settings.Settings, error) {
set := &settings.Settings{}
return set, get(s.db, "settings", set)
}
func (s settingsBackend) Save(set *settings.Settings) error {
return Save(s.db, "settings", set)
}
func (s settingsBackend) GetServer() (*settings.Server, error) {
server := &settings.Server{
Port: 80,
NumImageProcessors: 1,
}
return server, get(s.db, "server", server)
}
func (s settingsBackend) SaveServer(server *settings.Server) error {
return Save(s.db, "server", server)
}
Loaded 100 of 1211 files, more files were not shown because too many files have changed in this diff. Show more