Compare commits

..
205 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
Graham SteffaniakandKevin B. 3bfd9d9be0 Beta/v1.3.3 (#2234)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-04-03 13:40:25 -05:00
carloda ce5269e8e9 Fix and improve Italian translation (it.json) (#2198) 2026-03-30 15:57:45 -05:00
Kevin B. 6d21c7918e update spanish translation (#2200) 2026-03-30 15:57:25 -05:00
Graham Steffaniak 2693026f6c Revert "Update translation script" (#2182) 2026-03-23 19:22:18 -05:00
Kevin B. eb4dfa5710 Update translation script (#2178) 2026-03-23 19:21:25 -05:00
Stephan Paternotte 363619bcb4 Update nl.json - minor corrections (#2172) 2026-03-22 16:27:05 -05:00
ab9790427f Beta/v1.3.2 (#2169)
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>
2026-03-21 16:22:31 -05:00
Kevin B. eabdfd9aa2 fix wrong username in share settings (#2148) 2026-03-18 15:14:01 -05:00
Graham Steffaniak af08800667 Username enumeration fix (#2144) 2026-03-17 18:06:26 -05:00
Graham Steffaniak 319b2d06e6 upgrade ffmpeg to version 8.1 (#2139) 2026-03-17 12:57:34 -05:00
Graham Steffaniak 2d2b3ade62 deprecate maxArchiveSizeGb (#2137) 2026-03-17 11:22:46 -05:00
janakoram 513946bdbf changed folder downloads to stream archive directly (#2125) 2026-03-16 17:39:38 -05:00
Peter Dave Hello 86ed0891e0 Improve zh-TW Traditional Chinese locale (#2115) 2026-03-10 11:03:16 -05:00
Graham Steffaniak d8bf9422c2 Update README.md (#2116) 2026-03-10 11:03:07 -05:00
Stephan Paternotte a7169e8dad Update nl.json for Beta/v1.3.1 (#2111) 2026-03-10 09:10:37 -05:00
Mickael 72f9fcbd64 fix (docs): incorrect Filestash feature listing (#2109) 2026-03-09 18:59:18 -05:00
d192ede484 Beta/v1.3.1 (#2104)
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>
2026-03-07 17:24:57 -05:00
dependabot[bot] da0c8bd0f1 Bump the npm_and_yarn group across 1 directory with 2 updates (#2046)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 09:22:53 -06:00
Graham Steffaniak 5108330405 an new announcement (#2049) 2026-02-28 07:56:40 -06:00
1c7e3c2ad2 Beta/v1.3.0 (#2045)
Signed-off-by: Joel Godfrey <m.joelgodfrey@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
Co-authored-by: Joel Godfrey <82655889+BaccanoMob@users.noreply.github.com>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-02-27 18:45:59 -06:00
Graham Steffaniak 2eb352c2d3 fix delete item issue while viewing (#2043) 2026-02-27 16:29:49 -06:00
Graham Steffaniak 43bb4a36b4 stable/v1.2.1 (#2041) 2026-02-27 13:13:18 -06:00
Stephan Paternotte c67abefc7b Update nl.json (#2032) 2026-02-25 12:26:39 -06:00
Graham Steffaniak 2ecb9036aa Beta/v1.2.7 (#2027) 2026-02-24 12:55:04 -06:00
Graham Steffaniak dbcfba993b Beta/v1.2.6 (#2004) 2026-02-21 10:39:04 -06:00
Graham Steffaniak ed22ce9421 Beta/v1.2.5 (#1999) 2026-02-19 16:35:55 -06:00
Graham Steffaniakandzarev 8caa6d6a60 Beta/v1.2.4 (#1978)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-13 16:08:20 -06:00
Graham Steffaniak fb1af87048 Beta/v1.2.3 (#1940) 2026-02-06 13:52:38 -06:00
+4 f73ca51f94 Beta/v1.2.2 (#1928)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-02-03 18:07:51 -06:00
+4 ad839f92ac Beta/v1.2.1 (#1889)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-25 07:13:59 -06:00
dependabot[bot] eb47df7172 Bump lodash from 4.17.21 to 4.17.23 in /frontend in the npm_and_yarn group across 1 directory (#1882)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 10:26:15 -06:00
+4 f2f5d776ca Beta/v1.2.0 (#1858)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-20 15:56:52 -06:00
Graham Steffaniak 4f73b4cc6b Beta/v1.1.6 (#1812) 2026-01-06 13:08:54 -06:00
SmilingPie 5d7d94d86a fix(frontend): fix preview page sorting (#1787) 2026-01-03 10:09:29 -06:00
Graham SteffaniakandKevin B. 9723799e4f Beta/v1.1.5 (#1781)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2025-12-31 13:19:00 -06:00
Graham Steffaniak 3e5f0312f3 updated token claim process (#1774) 2025-12-31 11:13:10 -06:00
MrPanda376 131c3a3686 Bug fix: file permissions not working (#1762) 2025-12-28 15:43:20 -06:00
ATUR 7e5a579dde fix(http): handle Close() errors in archive creation (#1745) 2025-12-20 14:10:37 -06:00
Adam ABICHOU cb2d4dc518 Move custom CSS and user-selected theme styles to the end of the body… (#1744) 2025-12-20 13:53:20 -06:00
Graham SteffaniakandKevin B. 8587ffc092 Beta/v1.1.4 (#1724)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2025-12-15 11:14:40 -06:00
Graham Steffaniak de6fefad4e Beta/v1.1.3 (#1695) 2025-12-02 09:55:48 -06:00
9d18a369c7 Beta/v1.1.2 (#1679)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-30 13:34:00 -05:00
StefPanther 9b599347f7 Update French translation (#1682) 2025-11-30 11:18:29 -05:00
1292 changed files with 235679 additions and 74051 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"
]
}
}
}
+8 -1
View File
@@ -1,8 +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
-1
View File
@@ -1 +0,0 @@
.gitea/CODEOWNERS linguist-detectable=false
-3
View File
@@ -1,3 +0,0 @@
# Pull requests request review from the eligible staff team(s).
# One approval from either an Admin or Developer satisfies branch protection.
* @trueog/Admins @trueog/Developers
+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
+32 -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:
@@ -80,6 +82,14 @@ jobs:
dockerfile: ./_docker/Dockerfile.playwright-no-config
- name: settings
dockerfile: ./_docker/Dockerfile.playwright-settings
- name: screenshots
dockerfile: ./_docker/Dockerfile.playwright-screenshots
- name: previews
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
@@ -97,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
@@ -116,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
+44 -13
View File
@@ -7,28 +7,30 @@ 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
with:
go-version: 'stable'
- working-directory: backend
run: go test -race -v ./...
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,23 +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
run: npm i && npm run i18n:check
check-docs:
runs-on: ubuntu-24.04-arm
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- run: make generate-docs
- 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 }}
+63 -11
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,24 +61,47 @@ jobs:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
- 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)
) |
if any(.tags[]; test("beta")) then
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor (always) and major tags (when allowed)
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
if $updateMajor then
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
else empty end
] |
# 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",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
"ghcr.io/gtsteffaniak/filebrowser:beta"
]
else . end |
if any(.tags[]; test("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",
@@ -128,23 +153,50 @@ 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
) |
if any(.tags[]; test("beta")) then
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor (always) and major tags (when allowed)
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
if $updateMajor then
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
else empty end
] |
# 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 |
if any(.tags[]; test("stable")) 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"
+2 -2
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
@@ -27,7 +27,7 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/gtsteffaniak/playwright-base
tags: latest
tags: chromium
- name: Build and push playwright-base
uses: docker/build-push-action@v6
with:
+23 -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,18 +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
@@ -49,7 +69,7 @@ yarn-error.log*
*.sln
*.sw*
bin/
build/
/build/
*__debug*
backend/custom.css
+1078
View File
File diff suppressed because it is too large. Load diff
+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`
+46 -57
View File
@@ -1,31 +1,30 @@
<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: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
: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.
This version is called "Quantum" because it packs tons of advanced features into a tiny easy to run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
This version is called "Quantum" because it packs tons of advanced features into a tiny and easy-to-run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
The goal for this repo is to become the best open-source self-hosted file browsing application that exists -- **all for free**. This repo will always be free and open-source.
@@ -35,114 +34,104 @@ Ready to try it out? See [Getting Started Docs](https://filebrowserquantum.com/e
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
1. ✅ Add and configure multiple sources
2. ✅ Login support for OIDC, password + 2FA, and proxy.
1. ✅ Better source configuration - multiple sources, include/exclude rules, and [more](https://filebrowserquantum.com/en/docs/configuration/sources/)
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 [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
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**
- Thumbnails support includes **office**, **video**, and **album artwork**, and **3D models**.
- Faster and more responsive views with animations.
- Folder sizes are displayed and support for thumbnails
- **Folder sizes** are displayed and support thumbnails
- Navigating remembers the last scroll position.
7. ✅ Highly configurable and customizable sharing options
- 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.
Notable features that this fork *does not* have (removed):
- :construction: jobs are not supported yet.
- ❌ shell commands are completely removed and will not be returned.
FileBrowser Quantum differs significantly from the original version. Many of these changes required a significant overhaul. Creating a fork was a necessary process to make the program better. There have been many growing pains, but a stable release is planned and coming soon.
## System Requirements
> [!WARNING]
> Every file and directory in the source gets indexed (by default). This enables powerful features such as instant search, but large source filesystems can increase your system requirements. [See indexing wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.
- **Memory**: depends on configured source complexity. See [how much RAM does it require?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
- **GPU**: Not currently used (planned)
## The UI
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 | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav 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 | 256 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
has standalone binary | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
price | free | free | free | free tier | free tier | $99+ |
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
Custom job support | :construction: | ✅ | ❌ | ✅ | ❌ | ✅ |
Advanced Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
tags support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ❌ | ❌ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ✅ | ✅ | ❌ | ❌ |
file space quotas | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
activity log | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Themes | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
+15 -16
View File
@@ -1,34 +1,35 @@
FROM gtstef/ffmpeg:8.0.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 upx
RUN apk update && apk add --no-cache gcc musl-dev
RUN go mod download && go run ./scripts/setup-gofitz-cgo
ENV CGO_ENABLED=1
ENV GOEXPERIMENT=greenteagc
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 .
RUN upx 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
@@ -37,13 +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" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+5 -2
View File
@@ -1,4 +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 firefox
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
+7 -5
View File
@@ -9,12 +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
+14
View File
@@ -0,0 +1,14 @@
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
COPY [ "./_docker/src/jwt/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
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
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+22
View File
@@ -0,0 +1,22 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/tests/", "./tests" ]
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
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/"]
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
+17
View File
@@ -0,0 +1,17 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/previews/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./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 mv .htpasswd /etc/nginx/conf.d/.htpasswd
+16
View File
@@ -0,0 +1,16 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
COPY [ "./_docker/src/screenshots/frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
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
+9 -4
View File
@@ -7,14 +7,19 @@ COPY [ "./_docker/src/settings/", "./" ]
WORKDIR /app/frontend
COPY [ "./_docker/src/settings/frontend/", "./" ]
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 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
+12 -13
View File
@@ -3,26 +3,26 @@ ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
ENV GOEXPERIMENT=greenteagc
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 apk update && apk add
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 .
RUN upx 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
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
RUN adduser -D -s /bin/true -u 1000 filebrowser
@@ -32,10 +32,9 @@ 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" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+71 -1
View File
@@ -1,4 +1,55 @@
services:
local-playwright:
container_name: local-playwright
ports:
- "8080:8080"
build:
dockerfile: ./_docker/Dockerfile.playwright-local
context: ../
local-playwright-screenshots:
container_name: local-playwright
volumes:
- ../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-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
@@ -7,11 +58,30 @@ services:
volumes:
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
nginx-proxy-jwt:
image: nginx
container_name: nginx-proxy-jwt
ports:
- "8081:8081" # Admin user
- "8082:8082" # Regular user
- "8083:8083" # Wrong key
- "8084:8084" # No user
volumes:
- ./src/jwt/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/jwt/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
filebrowser-jwt:
hostname: filebrowser
volumes:
- '../frontend:/home/frontend'
- "./src/jwt/backend/config.yaml:/home/filebrowser/data/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile.slim
filebrowser:
hostname: filebrowser
volumes:
- '../frontend:/home/frontend'
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
- "./src/proxy/backend/config.yaml:/home/filebrowser/data/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile.slim
+10 -3
View File
@@ -1,15 +1,22 @@
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
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"
+53
View File
@@ -0,0 +1,53 @@
http:
port: 8080
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"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
auth:
methods:
password:
enabled: false
jwt:
enabled: true
secret: "test-jwt-secret-key-for-development-only-do-not-use-in-production"
algorithm: "HS256"
header: "X-JWT-Assertion"
userIdentifier: "sub"
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
+66
View File
@@ -0,0 +1,66 @@
# Admin user (port 8081)
server {
listen 8081;
server_name localhost 127.0.0.1;
set $admin_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NTc2NiwiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNTc2NiwibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDU3NjYsInN1YiI6InRlc3RhZG1pbiJ9.pxuKGyMmEe94m0xQ7y1QzcUZgU-lZSvqjoKUVoVd3F4";
location / {
proxy_set_header X-JWT-Assertion $admin_token;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# Regular user (port 8082)
server {
listen 8082;
server_name localhost 127.0.0.1;
set $regular_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJleHAiOjIwODc1NjU2OTIsImdyb3VwcyI6WyJteWdyb3VwIl0sImlhdCI6MTc3MjIwNTY5MiwibmFtZSI6IlRlc3QgVXNlciIsIm5iZiI6MTc3MjIwNTY5Miwic3ViIjoidGVzdHVzZXIifQ.X8fUYbhnZpgvP5CnGdms9eGo8yRLyNhRbj7xUSxEgl8";
location / {
proxy_set_header X-JWT-Assertion $regular_token;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# Valid token but signed with different secret (port 8083)
server {
listen 8083;
server_name localhost 127.0.0.1;
# Valid JWT token structure, but signed with wrong secret - should fail signature verification
set $wrong_key "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NjAyMywiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNjAyMywibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDYwMjMsInN1YiI6InRlc3RhZG1pbiJ9.afBobbJ3VQ-fKnUebGr-gxFIqegyICi6E3EgkUSeFNw";
location / {
proxy_set_header X-JWT-Assertion $wrong_key;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# No authentication (port 8084)
server {
listen 8084;
server_name localhost 127.0.0.1;
location / {
proxy_set_header X-JWT-Assertion "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// Simple JWT generator without external dependencies
func main() {
// Secret key - must match the one in your config file
secret := "wrong-key"
// Check if custom secret provided via command line
if len(os.Args) > 1 {
secret = os.Args[1]
}
// Create token claims with 10 year expiration
now := time.Now()
expiresAt := now.Add(10 * 365 * 24 * time.Hour) // 10 years
header := map[string]interface{}{
"alg": "HS256",
"typ": "JWT",
}
claims := map[string]interface{}{
"sub": "testadmin", // username (subject)
"email": "testadmin@example.com", // email claim
"name": "Test Admin", // full name
"groups": []string{"admin"}, // groups - includes admin for testing
"iat": now.Unix(), // issued at
"exp": expiresAt.Unix(), // expires at
"nbf": now.Unix(), // not before
}
// Encode header
headerJSON, err := json.Marshal(header)
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding header: %v\n", err)
os.Exit(1)
}
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
// Encode claims
claimsJSON, err := json.Marshal(claims)
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding claims: %v\n", err)
os.Exit(1)
}
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
// Create signature
message := headerB64 + "." + claimsB64
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(message))
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
// Combine to form complete JWT
tokenString := message + "." + signature
// Output the token
fmt.Println("=== JWT Token Generated ===")
fmt.Println()
fmt.Println("Token:")
fmt.Println(tokenString)
fmt.Println()
// Verify token can be decoded
parts := strings.Split(tokenString, ".")
if len(parts) == 3 {
fmt.Println("✓ Token structure is valid (3 parts)")
} else {
fmt.Fprintf(os.Stderr, "✗ Invalid token structure\n")
os.Exit(1)
}
// Save to file for easy access
filename := "test-jwt-token.txt"
if err := os.WriteFile(filename, []byte(tokenString), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not write token to file: %v\n", err)
} else {
fmt.Printf("✓ Token saved to: %s\n", filename)
fmt.Println()
}
// Also save to shell script for easy sourcing
scriptContent := fmt.Sprintf("#!/bin/bash\n# Source this file to set JWT token as environment variable\n# Usage: source jwt-env.sh\nexport TEST_JWT_TOKEN=\"%s\"\necho \"JWT token loaded into TEST_JWT_TOKEN\"\n", tokenString)
if err := os.WriteFile("jwt-env.sh", []byte(scriptContent), 0755); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not write shell script: %v\n", err)
} else {
fmt.Println("✓ Shell script saved to: jwt-env.sh")
fmt.Println(" Usage: source jwt-env.sh")
fmt.Println()
}
}
+3
View File
@@ -0,0 +1,3 @@
module test
go 1.25.6
@@ -0,0 +1,66 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/jwt-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/jwt",
/* 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,
/* Opt out of parallel tests on CI. */
workers: 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for different JWT test scenarios */
projects: [
{
name: "admin-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8081", // Admin user JWT
},
},
{
name: "regular-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8082", // Regular user JWT
},
},
{
name: "wrong-key",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8083", // Wrong secret key
},
},
{
name: "no-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8084", // No JWT token
},
},
],
});
+34 -27
View File
@@ -1,37 +1,46 @@
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
conditionals:
rules:
- folderPath: "excluded"
- folderPath: "excludedButVisible"
viewable: true
defaultPermissions:
view: true
download: true
modify: true
create: true
rules:
- folderPath: "excluded"
- folderPath: "excludedButVisible"
viewable: true
- fileStartsWith: "startsWith"
- fileName: "dontshow.txt"
- path: "../frontend/tests/playwright-files2"
name: "include"
config:
defaultEnabled: true
conditionals:
rules:
- includeRootItem: "myfolder"
- includeRootItem: "subfolderExclusions"
- filePath: "/subfolderExclusions/exclusionlist.sh"
- folderPath: "/subfolderExclusions/folderItem"
viewable: true
- fileStartsWith: "startsWith"
- folderEndsWith: "Folder"
- folderNames: 'folderNames'
- fileNames: 'fileNames'
- folderName: 'folderName'
- fileName: 'fileName'
- folderName: '.hiddenDir'
- fileName: '.hidden'
useLogicalSize: true
rules:
- includeRootItem: "myfolder"
- includeRootItem: "subfolderExclusions"
- includeRootItem: "folder#hash"
- includeRootItem: "files"
- filePath: "/subfolderExclusions/exclusionlist.sh"
- folderPath: "/subfolderExclusions/folderItem"
viewable: true
- fileStartsWith: "startsWith"
- folderEndsWith: "Folder"
- folderName: "folderName"
- fileName: "fileName"
- fileName: "fileNames"
- folderName: ".hiddenDir"
- fileName: ".hidden"
auth:
methods:
@@ -48,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"
+41
View File
@@ -0,0 +1,41 @@
http:
port: 80
baseURL: "/"
server:
logging:
- 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:
noauth: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
userDefaults:
listing:
showHidden: true
preview:
image: true
popup: true
video: true
audio: true
office: true
models: true
highQuality: true
@@ -0,0 +1,54 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/previews-setup",
timeout: 10000,
testDir: "./tests/playwright/previews",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
workers: 10,
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
launchOptions: {
args: [
'--disable-gpu',
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
'--enable-webgl',
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
'--no-sandbox', // Required for Docker
],
},
storageState: "previews.json",
actionTimeout: 5000,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+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
@@ -11,14 +11,14 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/proxy-setup.ts",
timeout: 5000,
timeout: 10000,
testDir: "./tests/playwright/proxy",
/* 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: 3,
/* Opt out of parallel tests on CI. */
workers: 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
@@ -0,0 +1,22 @@
http:
port: 8080
baseURL: "/files/"
auth:
adminPassword: "playwright-password"
server:
logging:
- 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
@@ -0,0 +1,66 @@
import { defineConfig, devices } from "@playwright/test";
import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig<PlaywrightFixtureOptions>({
globalSetup: "./tests/playwright/screenshots-setup.ts",
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: 1,
/* Opt out of parallel tests on CI. */
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
launchOptions: {
args: [
'--disable-gpu',
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
'--enable-webgl',
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
'--no-sandbox', // Required for Docker
],
},
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:8080",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "dark-screenshots",
use: {
...devices["Desktop Chrome"],
theme: 'dark',
},
},
{
name: "light-screenshots",
use: {
...devices["Desktop Chrome"],
theme: 'light',
},
},
],
});
+22 -4
View File
@@ -1,18 +1,31 @@
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:
defaultEnabled: true
denyByDefault: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
@@ -28,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
+1
View File
@@ -3,3 +3,4 @@ air.log
filebrowser
generated.yaml
test_config.yaml
/data
+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:
-342
View File
@@ -1,342 +0,0 @@
package files
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func Test_GetRealPath(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
return
}
trimPrefix := filepath.Dir(filepath.Dir(cwd))
tests := []struct {
name string
paths []string
want struct {
path string
isDir bool
}
}{
{
name: "current directory",
paths: []string{
"./",
},
want: struct {
path string
isDir bool
}{
path: "",
isDir: true,
},
},
{
name: "current directory",
paths: []string{
"./files/file.go",
},
want: struct {
path string
isDir bool
}{
path: "/files/file.go",
isDir: false,
},
},
{
name: "other test case",
paths: []string{
"/mnt/doesnt/exist",
},
want: struct {
path string
isDir bool
}{
path: "/mnt/doesnt/exist",
isDir: false,
},
},
}
idx := indexing.Index{
Source: settings.Source{
Path: trimPrefix,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
realPath, isDir, _ := idx.GetRealPath(tt.paths...)
adjustedRealPath := strings.TrimPrefix(realPath, trimPrefix)
if tt.want.path != adjustedRealPath || tt.want.isDir != isDir {
t.Errorf("expected %v:%v but got: %v:%v", tt.want.path, tt.want.isDir, adjustedRealPath, isDir)
}
})
}
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
input iteminfo.FileInfo
expected iteminfo.FileInfo
}{
{
name: "Numeric and Lexicographical Sorting",
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "10.txt"},
{Name: "2.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
},
},
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "2.txt"},
{Name: "10.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
},
},
},
{
name: "Only Lexicographical Sorting",
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "dog.txt"},
{Name: "Cat.txt"},
{Name: "apple"},
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
},
},
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "Cat.txt"},
{Name: "dog.txt"},
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.input.SortItems()
getFolderNames := func(items []iteminfo.ItemInfo) []string {
names := []string{}
for _, folder := range items {
names = append(names, folder.Name)
}
return names
}
getFileNames := func(items []iteminfo.ExtendedItemInfo) []string {
names := []string{}
for _, file := range items {
names = append(names, file.Name)
}
return names
}
actualFolderNames := getFolderNames(test.input.Folders)
expectedFolderNames := getFolderNames(test.expected.Folders)
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
}
actualFileNames := getFileNames(test.input.Files)
expectedFileNames := getFileNames(test.expected.Files)
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
}
})
}
}
// TestDeleteFilesCacheClearing was removed because cache clearing is not performed
// per design decision - RealPathCache is auxiliary and doesn't need clearing
func TestOverrideDirectoryToFile(t *testing.T) {
// Initialize the index in mock mode (no filesystem operations)
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
// Get the index and set up mock data
idx := indexing.GetIndex("test")
if idx == nil { //nolint:staticcheck // t.Fatal terminates execution
t.Fatal("Failed to get test index")
}
// Create mock directory structure
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
},
Folders: []iteminfo.ItemInfo{
{Name: "Test Object", Type: "directory"},
},
}
// Simulate the directory-to-file override by updating the mock data
// Remove the directory from the parent's Folders slice
rootInfo := idx.Directories["/"] //nolint:staticcheck // t.Fatal terminates execution
for i, folder := range rootInfo.Folders {
if folder.Name == "Test Object" {
rootInfo.Folders = append(rootInfo.Folders[:i], rootInfo.Folders[i+1:]...)
break
}
}
// Add the file to the parent's Files slice
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "Test Object",
Size: 25, // Length of "This is test file content"
},
})
// Verify the directory was replaced with a file in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
// Check that the directory was removed from Folders
foundDir := false
for _, folder := range rootInfo.Folders {
if folder.Name == "Test Object" {
foundDir = true
break
}
}
if foundDir {
t.Error("Directory 'Test Object' should have been removed from Folders")
}
// Check that the file was added to Files
foundFile := false
for _, file := range rootInfo.Files {
if file.Name == "Test Object" {
foundFile = true
if file.Size != 25 {
t.Errorf("Expected file size 25, got %d", file.Size)
}
break
}
}
if !foundFile {
t.Error("File 'Test Object' should have been added to Files")
}
}
func TestOverrideFileToDirectory(t *testing.T) {
// Initialize the index in mock mode (no filesystem operations)
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
// Get the index and set up mock data
idx := indexing.GetIndex("test")
if idx == nil { //nolint:staticcheck // t.Fatal terminates execution
t.Fatal("Failed to get test index")
}
// Create mock directory structure with a file
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
},
}
// Simulate the file-to-directory override by updating the mock data
// Remove the file from the parent's Files slice
rootInfo := idx.Directories["/"] //nolint:staticcheck // t.Fatal terminates execution
for i, file := range rootInfo.Files {
if file.Name == "Test Object" {
rootInfo.Files = append(rootInfo.Files[:i], rootInfo.Files[i+1:]...)
break
}
}
// Add the directory to the parent's Folders slice
rootInfo.Folders = append(rootInfo.Folders, iteminfo.ItemInfo{
Name: "Test Object",
Type: "directory",
})
// Verify the file was replaced with a directory in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
// Check that the file was removed from Files
foundFile := false
for _, file := range rootInfo.Files {
if file.Name == "Test Object" {
foundFile = true
break
}
}
if foundFile {
t.Error("File 'Test Object' should have been removed from Files")
}
// Check that the directory was added to Folders
foundDir := false
for _, folder := range rootInfo.Folders {
if folder.Name == "Test Object" {
foundDir = true
if folder.Type != "directory" {
t.Errorf("Expected directory type, got %s", folder.Type)
}
break
}
}
if !foundDir {
t.Error("Directory 'Test Object' should have been added to Folders")
}
}
-770
View File
@@ -1,770 +0,0 @@
package files
import (
"context"
"encoding/base64"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/dhowden/tag"
"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/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
index := indexing.GetIndex(opts.Source)
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
if !strings.HasPrefix(opts.Path, "/") {
opts.Path = "/" + opts.Path
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
return response, fmt.Errorf("could not get real path for requested path: %v, error: %v", opts.Path, err)
}
if !strings.HasSuffix(opts.Path, "/") && isDir {
opts.Path = opts.Path + "/"
}
opts.IsDir = isDir
var info *iteminfo.FileInfo
// Check if path is viewable (allows filesystem access without indexing)
isViewable := index.IsViewable(isDir, opts.Path)
// For non-viewable paths, verify they are indexed
// Skip this check if indexing is disabled for the entire source
if !isViewable && !index.Config.DisableIndexing {
err = index.RefreshFileInfo(opts)
if err != nil {
return response, fmt.Errorf("path not accessible: %v", err)
}
}
if isDir {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
} else {
// For files, get info from parent directory to ensure HasPreview is set correctly
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
}
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
if access != nil && !access.Permitted(index.Path, opts.Path, opts.Username) {
// User doesn't have access to the current folder, but check if they have access to any subitems
// This allows specific allow rules on subfolders/files to work even when parent is denied
err := access.CheckChildItemAccess(response, index, opts.Username)
if err != nil {
return response, err
}
}
// For directories, populate metadata for audio/video files ONLY if explicitly requested
// This avoids expensive ffprobe calls on every directory listing
if isDir && opts.Metadata {
startTime := time.Now()
metadataCount := 0
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if isItemAudio || isItemVideo {
// Get the real path for this file
itemRealPath, _, _ := index.GetRealPath(opts.Path, fileItem.Name)
// Extract metadata for audio files (without album art for performance)
if isItemAudio {
err := extractAudioMetadata(context.Background(), fileItem, itemRealPath, opts.AlbumArt || opts.Content, opts.Metadata)
if err != nil {
logger.Debugf("failed to extract metadata for file: "+fileItem.Name, err)
} else {
metadataCount++
}
} else if isItemVideo {
// Extract duration for video files
err := extractVideoMetadata(context.Background(), fileItem, itemRealPath)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+fileItem.Name, err)
} else {
metadataCount++
}
}
}
}
if metadataCount > 0 {
elapsed := time.Since(startTime)
logger.Debugf("Extracted metadata for %d audio/video files in %v (avg: %v per file)",
metadataCount, elapsed, elapsed/time.Duration(metadataCount))
}
}
// Extract content/metadata when explicitly requested OR for single file audio/video requests
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
if opts.Content || opts.Metadata || (!isDir && isAudioVideo) {
processContent(response, index, opts)
}
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
return response, nil
}
func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
isVideo := strings.HasPrefix(info.Type, "video")
isAudio := strings.HasPrefix(info.Type, "audio")
isFolder := info.Type == "directory"
if isFolder {
return
}
if isVideo {
// Extract duration for video
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractVideoMetadata(context.Background(), extItem, info.RealPath)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
} else {
info.Metadata = extItem.Metadata
}
// Handle subtitles if requested
if opts.ExtractEmbeddedSubtitles {
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
if exists {
info.DetectSubtitles(parentInfo)
err := info.LoadSubtitleContent()
if err != nil {
logger.Debug("failed to load subtitle content: " + err.Error())
}
}
}
return
}
if isAudio {
// Create an ExtendedItemInfo to hold the metadata
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
// Copy metadata to ExtendedFileInfo
info.Metadata = extItem.Metadata
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
}
return
}
// Process text content for non-video, non-audio files
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(info.RealPath)
if err != nil {
logger.Debugf("could not get content for file: "+info.RealPath, info.Name, err)
return
}
info.Content = content
} else {
logger.Debug("skipping large text file contents (20MB limit): "+info.Path, info.Name, info.Type)
}
}
func generateOfficeId(realPath string) string {
key, ok := utils.OnlyOfficeCache.Get(realPath)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
utils.OnlyOfficeCache.Set(realPath, documentKey)
return documentKey
}
return key
}
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
// and optionally extracts duration using the ffmpeg service with concurrency control
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool) error {
file, err := os.Open(realPath)
if err != nil {
return err
}
defer file.Close()
// Check file size first to prevent reading extremely large files
fileInfo, err := file.Stat()
if err != nil {
return err
}
// Skip files larger than 300MB to prevent memory issues
maxSize := int64(300)
if fileInfo.Size() > maxSize*1024*1024 {
return fmt.Errorf("file with size %d MB exceeds metadata check limit: %d MB", fileInfo.Size()/1024/1024, maxSize)
}
m, err := tag.ReadFrom(file)
if err != nil {
return err
}
item.Metadata = &iteminfo.MediaMetadata{
Title: m.Title(),
Artist: m.Artist(),
Album: m.Album(),
Year: m.Year(),
Genre: m.Genre(),
}
// Extract track number
track, _ := m.Track()
item.Metadata.Track = track
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
// This respects concurrency limits and gracefully handles missing ffmpeg
if getDuration {
ffmpegService := ffmpeg.NewFFmpegService(5, false, "")
if ffmpegService != nil {
startTime := time.Now()
if duration, err := ffmpegService.GetMediaDuration(ctx, realPath); err == nil {
item.Metadata.Duration = int(duration)
elapsed := time.Since(startTime)
if elapsed > 50*time.Millisecond {
logger.Debugf("Duration extraction took %v for file: %s", elapsed, item.Name)
}
}
}
}
if !getArt {
return nil
}
// Extract album art and encode as base64 with strict size limits
if picture := m.Picture(); picture != nil && picture.Data != nil {
// More aggressive size limit to prevent memory issues (max 5MB)
if len(picture.Data) <= 5*1024*1024 {
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
}
}
return nil
}
// extractVideoMetadata extracts duration from video files using ffprobe
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string) error {
// Extract duration using the ffmpeg VideoService with concurrency control
videoService := ffmpeg.NewFFmpegService(10, false, "")
if videoService != nil {
duration, err := videoService.GetMediaDuration(ctx, realPath)
if err != nil {
return err
}
if duration > 0 {
item.Metadata = &iteminfo.MediaMetadata{
Duration: int(duration),
}
}
return nil
}
return nil
}
func DeleteFiles(source, absPath string, absDirPath string, isDir bool) error {
index := indexing.GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
if !index.Config.DisableIndexing {
indexPath := index.MakeIndexPath(absPath)
// Perform the physical deletion
err := os.RemoveAll(absPath)
if err != nil {
return err
}
// Remove metadata from index
if isDir {
// Recursively remove directory and all subdirectories from index
index.DeleteMetadata(indexPath, true, true)
} else {
// Remove file from parent's file list
index.DeleteMetadata(indexPath, false, false)
}
// Refresh the parent directory to recalculate sizes and update counts
// This will traverse up the tree and update all parent sizes correctly
refreshConfig := utils.FileOptions{
Path: index.MakeIndexPath(absDirPath),
IsDir: true,
}
err = index.RefreshFileInfo(refreshConfig)
return err
}
// Indexing disabled, just delete the file
return os.RemoveAll(absPath)
}
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", source)
}
if idx.Config.DisableIndexing {
return nil
}
// Always normalize path using MakeIndexPath
path = idx.MakeIndexPath(path)
// MakeIndexPath always adds trailing slash, but for files we need to remove it
if !isDir {
path = strings.TrimSuffix(path, "/")
}
// Skip indexing for viewable paths (viewable: true means don't index, just allow FS access)
if idx.IsViewable(isDir, path) {
return nil
}
// For directories, check if the path exists on disk
// If it doesn't exist, remove it from the index
if isDir {
realPath, _, err := idx.GetRealPath(path)
if err == nil {
// Check if the directory exists on disk
if !Exists(realPath) {
// Directory no longer exists, remove it from the index
// This clears both Directories and DirectoriesLedger maps
idx.DeleteMetadata(path, true, false)
return nil
}
}
}
err := idx.RefreshFileInfo(utils.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
return err
}
// validateMoveDestination checks if a move/rename operation is valid
// It prevents moving a directory into itself or its subdirectories
func validateMoveDestination(src, dst string, isSrcDir bool) error {
// Clean and normalize paths
src = filepath.Clean(src)
dst = filepath.Clean(dst)
// If source is a directory, check if destination is within source
if isSrcDir {
// Get the parent directory of the destination
dstParent := filepath.Dir(dst)
// Check if destination parent is the source directory or a subdirectory of it
if strings.HasPrefix(dstParent+string(filepath.Separator), src+string(filepath.Separator)) || dstParent == src {
return fmt.Errorf("cannot move directory '%s' to a location within itself: '%s'", src, dst)
}
}
// Check if destination parent directory exists
dstParent := filepath.Dir(dst)
if dstParent != "." && dstParent != "/" {
if _, err := os.Stat(dstParent); os.IsNotExist(err) {
return fmt.Errorf("destination directory does not exist: '%s'", dstParent)
}
}
return nil
}
func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage, a *access.Storage) error {
// Check if source and destination are the same file
if realsrc == realdst {
return fmt.Errorf("cannot move a file to itself: %s", realsrc)
}
// Validate the move operation before executing
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
return err
}
// Get indexes for deletion and refresh operations
srcIdx := indexing.GetIndex(sourceIndex)
if srcIdx == nil {
return fmt.Errorf("could not get source index: %v", sourceIndex)
}
dstIdx := indexing.GetIndex(destIndex)
if dstIdx == nil {
return fmt.Errorf("could not get destination index: %v", destIndex)
}
// Prepare paths for index operations
srcIndexPath := srcIdx.MakeIndexPath(realsrc)
srcParentPath := filepath.Dir(realsrc)
// Perform the physical move
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
// Handle SOURCE cleanup (treat as deletion)
if !srcIdx.Config.DisableIndexing {
// Remove metadata from source index
if isSrcDir {
// Recursively remove directory and all subdirectories from source index
srcIdx.DeleteMetadata(srcIndexPath, true, true)
} else {
// Remove file from source parent's file list
srcIdx.DeleteMetadata(srcIndexPath, false, false)
}
// Refresh the source parent directory to recalculate sizes and update counts
go RefreshIndex(sourceIndex, srcParentPath, true, false) //nolint:errcheck
}
// Handle DESTINATION indexing
if !dstIdx.Config.DisableIndexing {
if isSrcDir {
go func() {
// When moving a folder, refresh the folder itself recursively FIRST
// Must complete before parent refresh to avoid race condition
RefreshIndex(destIndex, realdst, true, true) //nolint:errcheck
// THEN refresh the parent directory so it sees the newly indexed child
parentDir := filepath.Dir(realdst)
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
}()
} else {
// If moving a file, just refresh the parent directory
parentDir := filepath.Dir(realdst)
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
}
}
// Use backend source paths to match how shares are stored
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.Path, dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
// Update access rules for the moved path
if a != nil {
// If moving within the same source, update the rules
if srcIdx.Path == dstIdx.Path {
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
}
// Cross-source moves don't preserve access rules (they're source-specific)
}
return nil
}
func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string) error {
// Check if source and destination are the same file
if realsrc == realdst {
return fmt.Errorf("cannot copy a file to itself: %s", realsrc)
}
// Validate the copy operation before executing
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
return err
}
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
return err
}
// For copy operations:
// 1. Shallow refresh of source (just to update access times if needed)
// 2. Recursively index the destination to capture the entire copied tree
// Refresh source (parent directory if it's a file)
srcRefreshPath := realsrc
srcRefreshIsDir := isSrcDir
if !isSrcDir {
srcRefreshPath = filepath.Dir(realsrc)
srcRefreshIsDir = true
}
go RefreshIndex(sourceIndex, srcRefreshPath, srcRefreshIsDir, false) //nolint:errcheck
// Refresh destination (parent directory if it's a file)
dstRefreshPath := realdst
if !isSrcDir {
// If copying a file (regardless of destination), refresh the parent directory
dstRefreshPath = filepath.Dir(realdst)
}
go RefreshIndex(destIndex, dstRefreshPath, true, true) //nolint:errcheck
return nil
}
func WriteDirectory(opts utils.FileOptions) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
var stat os.FileInfo
var err error
// Check if the destination exists and is a file
if stat, err = os.Stat(realPath); err == nil && !stat.IsDir() {
// If it's a file and we're trying to create a directory, remove the file first
err = os.Remove(realPath)
if err != nil {
return fmt.Errorf("could not remove existing file to create directory: %v", err)
}
}
// Ensure the parent directories exist
// Permissions are set by MkdirAll (subject to umask, which is usually acceptable)
err = os.MkdirAll(realPath, fileutils.PermDir)
if err != nil {
return err
}
return RefreshIndex(idx.Name, opts.Path, true, true)
}
func WriteFile(source, path string, in io.Reader) error {
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", source)
}
realPath, _, _ := idx.GetRealPath(path)
// Strip trailing slash from realPath if it's meant to be a file
realPath = strings.TrimRight(realPath, "/")
// Ensure the parent directories exist
parentDir := filepath.Dir(realPath)
err := os.MkdirAll(parentDir, fileutils.PermDir)
if err != nil {
return err
}
var stat os.FileInfo
// Check if the destination exists and is a directory
if stat, err = os.Stat(realPath); err == nil {
if stat.IsDir() {
// If it's a directory and we're trying to create a file, remove the directory first
err = os.RemoveAll(realPath)
if err != nil {
return fmt.Errorf("could not remove existing directory to create file: %v", err)
}
}
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
// For new files: permissions are set to fileutils.PermFile (subject to umask, which is usually acceptable)
// For existing files: permissions are preserved automatically (O_TRUNC doesn't change them)
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
defer file.Close()
// Copy the contents from the reader to the file
_, err = io.Copy(file, in)
if err != nil {
return err
}
return RefreshIndex(source, path, false, false)
}
// getContent reads and returns the file content if it's considered an editable text file.
func getContent(realPath string) (string, error) {
const headerSize = 4096
// Thresholds for detecting binary-like content (these can be tuned)
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
// Open file
f, err := os.Open(realPath)
if err != nil {
return "", err
}
defer f.Close()
// Read header
headerBytes := make([]byte, headerSize)
n, err := f.Read(headerBytes)
if err != nil && err != io.EOF {
return "", err
}
actualHeader := headerBytes[:n]
// --- Start of new heuristic checks ---
if n > 0 {
// Trim header to last complete UTF-8 rune to avoid false negatives
// when the header read cuts off in the middle of a multi-byte sequence.
// We decode runes from the end until we find a valid one, trimming
// any incomplete sequences at the end.
trimmedHeader := actualHeader
for len(trimmedHeader) > 0 {
lastRune, size := utf8.DecodeLastRune(trimmedHeader)
if lastRune != utf8.RuneError {
// Found a valid complete rune
break
}
// RuneError occurred - this could be an incomplete sequence or invalid byte
// Trim the last byte and try again
if size == 1 && len(trimmedHeader) > 0 {
trimmedHeader = trimmedHeader[:len(trimmedHeader)-1]
} else {
// Shouldn't happen, but break to avoid infinite loop
break
}
}
// 1. Basic Check: Is the header valid UTF-8?
// If not, it's unlikely an editable UTF-8 text file.
// Use trimmed header to avoid false negatives from truncated sequences
if len(trimmedHeader) > 0 && !utf8.Valid(trimmedHeader) {
return "", nil // Not an error, just not the text file we want
}
// 2. Check for excessive null bytes in the header
nullCountInHeader := 0
for _, b := range actualHeader {
if b == 0x00 {
nullCountInHeader++
}
}
// Reject if too many nulls absolutely or relatively in the header
if nullCountInHeader > 0 { // Only perform check if there are any nulls
if nullCountInHeader > maxNullBytesInHeaderAbs ||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
return "", nil // Too many nulls in header
}
}
// 3. Check for other non-text ASCII control characters in the header
// (C0 controls excluding \t, \n, \r)
for _, b := range actualHeader {
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
return "", nil // Found problematic control character
}
// C1 control characters (0x80-0x9F) would be caught by utf8.Valid if part of invalid sequences,
// or by the non-printable rune check later if they form valid (but undesirable) codepoints.
}
// Optional: Use http.DetectContentType for an additional check on the header
// contentType := http.DetectContentType(actualHeader)
// if !strings.HasPrefix(contentType, "text/") && contentType != "application/octet-stream" {
// // If it's clearly a non-text MIME type (e.g., "image/jpeg"), reject it.
// // "application/octet-stream" is ambiguous, so we rely on other heuristics.
// return "", nil
// }
}
// --- End of new heuristic checks for header ---
// Now read the full file (original logic)
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
// Handle empty file (original logic - returns specific string)
if len(content) == 0 {
return "empty-file-x6OlSil", nil
}
stringContent := string(content)
// 4. Final UTF-8 validation for the entire file
// (This is crucial as the header might be fine, but the rest of the file isn't)
if !utf8.ValidString(stringContent) {
return "", nil
}
// 5. Check for excessive null bytes in the entire file content
if len(content) > 0 { // Check only for non-empty files
totalNullCount := 0
for _, b := range content {
if b == 0x00 {
totalNullCount++
}
}
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
return "", nil // Too many nulls in the entire file
}
}
// 6. Check for excessive non-printable runes in the entire file content
// (Excluding tab, newline, carriage return, which are common in text files)
if len(stringContent) > 0 { // Check only for non-empty strings
nonPrintableRuneCount := 0
totalRuneCount := 0
for _, r := range stringContent {
totalRuneCount++
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
// It excludes control characters. We explicitly allow \t, \n, \r.
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
nonPrintableRuneCount++
}
}
if totalRuneCount > 0 { // Avoid division by zero
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
return "", nil // Too many non-printable runes
}
}
}
// The file has passed all checks and is considered editable text.
return stringContent, nil
}
func IsNamedPipe(mode os.FileMode) bool {
return mode&os.ModeNamedPipe != 0
}
func IsSymlink(mode os.FileMode) bool {
return mode&os.ModeSymlink != 0
}
func Exists(path string) bool {
_, err := os.Stat(path)
if err == nil {
return true
}
if os.IsNotExist(err) {
return false
}
return false
}
-68
View File
@@ -1,68 +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"
)
// 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 {
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
}
// create directory and append user name
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
// If parent directory doesn't exist and createUserDir is enabled, create it
if !Exists(parentDir) {
if err := MakeUserDir(parentDir); err != nil {
return fmt.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
}
}
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
}
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
// create directory exactly as specified
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if !Exists(parentDir) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
}
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: failed to create user home dir: %s", err)
}
} else {
// just assigning scope to path provided, so just check that it exists
path := filepath.Join(source.Path, scope.Scope)
if !Exists(path) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
}
}
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)
}
})
}
}
-34
View File
@@ -1,34 +0,0 @@
package auth
import (
"net/http"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var (
revokedApiKeyList map[string]struct{}
revokeMu sync.Mutex
)
func init() {
revokedApiKeyList = make(map[string]struct{})
}
// Auther is the authentication interface.
type Auther interface {
// Auth is called to authenticate a request.
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
}
func IsRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
}
func RevokeAPIKey(key string) {
revokeMu.Lock()
defer revokeMu.Unlock()
revokedApiKeyList[key] = struct{}{}
}
-109
View File
@@ -1,109 +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/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")
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, err := userStore.Get(username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
err = users.CheckPwd(password, user.Password)
if err != nil {
return nil, 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
}
-108
View File
@@ -1,108 +0,0 @@
== Running benchmark ==
/usr/local/go/bin/go
? github.com/gtsteffaniak/filebrowser/backend [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.005s
2025/11/07 17:48:07 [INFO] initializing index: [test]
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:07 [INFO] initializing index: [test]
2025/11/07 17:48:07 [INFO] initializing index: [test]
2025/11/07 17:48:07 [ERROR] Root scanner error: open /tmp/filebrowser_test3280924123/: no such file or directory
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 0.006s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/cmd 0.014s
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/common/settings
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
10 374600 ns/op 192030 B/op 3237 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.139s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.004s
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/access 0.287s
? github.com/gtsteffaniak/filebrowser/backend/database/crud [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt 0.842s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.003s
? github.com/gtsteffaniak/filebrowser/backend/docs [no test files]
? github.com/gtsteffaniak/filebrowser/backend/events [no test files]
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/ffmpeg
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkGetOrientationFilter-8 10 63.00 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/ffmpeg 0.006s
2025/11/07 17:48:08 [DEBUG] Saving user [admin] changepass: true
2025/11/07 17:48:08 [DEBUG] Saving user [non-admin] changepass: true
PASS
ok github.com/gtsteffaniak/filebrowser/backend/http 0.248s
2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/indexing
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
10 3611835 ns/op 323676 B/op 1655 allocs/op
2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
BenchmarkSearchAllIndexes-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
10 784497 ns/op 97700 B/op 1061 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/indexing 0.077s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.006s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.130s
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
+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 -45
View File
@@ -4,30 +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 {
// Clear cache before migration to ensure we get fresh data
store.Access.ClearCacheForSource(sourcePath)
// 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, "/") {
@@ -42,79 +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 {
store.Access.ClearCacheForSource(sourcePath)
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)
}
}
+202 -246
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,182 +21,142 @@ var (
configPath string
)
// return bool to indicate if the program should continue running
func runCLI() bool {
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("--path / -p is required (index path within the source, e.g. /)")
}
if ruleCategory == "" {
return fmt.Errorf("role is required: use --role / -r <user|group|all>")
}
if ruleCategory != "all" && value == "" {
return fmt.Errorf("value is required when role is 'user' or 'group': use --value / -v <username|groupname>")
}
generateYaml()
parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true)
if err != nil {
return err
}
// 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 allow {
switch ruleCategory {
case "user":
err = state.AllowUser(backendSourcePath, parsedPath, value)
case "group":
err = state.AllowGroup(backendSourcePath, parsedPath, value)
default:
return fmt.Errorf("invalid role for allow: must be 'user' or 'group'")
}
} else {
switch ruleCategory {
case "user":
err = state.DenyUser(backendSourcePath, parsedPath, value)
case "group":
err = state.DenyGroup(backendSourcePath, parsedPath, value)
case "all":
err = state.DenyAll(backendSourcePath, parsedPath)
default:
return fmt.Errorf("invalid role: must be 'user', 'group', or 'all'")
}
}
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)
if err != nil {
return fmt.Errorf("failed to add or update rule: %w", err)
}
action := "deny"
if allow {
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, backendSourcePath)
} else {
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(username, password string, asAdmin bool) error {
user, err := state.GetUserByUsername(username)
if err != nil {
newUser := users.User{
FrontendUser: users.FrontendUser{
Username: username,
LoginMethod: users.LoginMethodPassword,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.BackendScopes = append(newUser.BackendScopes, users.BackendScope{
Path: source.Path,
Scope: source.Config.DefaultUserScope,
})
}
}
if asAdmin {
logger.Infof("Creating user as admin: %s\n", username)
} else {
configPath = "config.yaml"
logger.Infof("Creating non-admin user: %s\n", username)
}
}
// 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
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")
// 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":
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
userInfo := strings.Split(user, ",")
if len(userInfo) < 2 {
fmt.Printf("not enough info to create user: \"set -u username,password\", only provided %v\n", userInfo)
setCmd.PrintDefaults()
os.Exit(1)
}
username := userInfo[0]
password := userInfo[1]
_ = getStore(dbConfig) // ignore bool check
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
Username: username,
LoginMethod: users.LoginMethodPassword,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
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.Admin = asAdmin
err = storage.CreateUser(newUser, newUser.Permissions)
if err != nil {
logger.Errorf("could not create user: %v", err)
}
return false
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Fatalf("user %s is not allowed to login with password authentication, cannot set password", username)
}
user.Password = password
user.TOTPSecret = "" // reset TOTP secret if it exists
user.TOTPNonce = "" // reset TOTP nonce if it exists
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
}
err = store.Users.Save(user, true, false)
if err != nil {
logger.Errorf("could not update user: %v", err)
}
fmt.Printf("successfully updated user: %s\n", username)
return false
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
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Account.Permissions)
newUser.Permissions.Admin = asAdmin
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
}
return true
if user.LoginMethod != users.LoginMethodPassword {
logger.Infof("Converting user %s from %s to password login method", username, user.LoginMethod)
}
user.TOTPSecret = ""
user.TOTPNonce = ""
user.LoginMethod = users.LoginMethodPassword
if asAdmin {
user.Permissions.Admin = true
}
if user.Version == 0 {
user.Version = users.CurrentUserMigrationVersion
}
err = state.UpdateUser(&user, password, "password", "totpSecret", "totpNonce", "loginMethod", "permissions", "version")
if err != nil {
return fmt.Errorf("could not update user: %v", err)
}
fmt.Printf("successfully updated user: %s\n", username)
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')
@@ -208,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)
@@ -225,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() {
@@ -362,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"
)
+127 -52
View File
@@ -3,58 +3,71 @@ package cmd
import (
"context"
"fmt"
"io/fs"
"os"
"os/signal"
"syscall"
"time"
_ "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/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 != "" {
@@ -73,19 +86,19 @@ 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)
}
err = utils.SetInvalidPasswordHash()
if err != nil {
logger.Fatalf("Failed to set security hash: %v", err)
}
sourceList := []string{}
for path, source := range settings.Config.Server.SourceMap {
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
@@ -96,25 +109,48 @@ func StartFilebrowser() {
logger.Info(database)
logger.Infof("Sources : %v", sourceList)
logger.Debugf("Using Embedded FS : %v", settings.Env.EmbeddedFs)
walModeStr := "OFF"
if settings.Config.Server.IndexSqlConfig.WalMode {
walModeStr = "WAL"
}
logger.Infof("SQL Journal Mode : %v", walModeStr)
if settings.Config.Server.CacheDirCleanup {
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
}
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 {
logger.Fatal("No sources configured, exiting...")
}
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false)
// Initialize shared index database before starting HTTP service
isNewDb, err := indexing.InitializeIndexDB()
if err != nil {
logger.Fatalf("Failed to initialize index database: %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
@@ -127,28 +163,67 @@ func StartFilebrowser() {
case <-done:
logger.Info("Server stopped unexpectedly. Shutting down...")
}
if !*settings.Config.Server.CacheDirCleanup {
// Stop all indexing scanners before closing the database
indexing.StopAllScanners()
// Give scanners a moment to finish their current scan operations
time.Sleep(100 * time.Millisecond)
// cleanup temp databases
indexDB := indexing.GetIndexDB()
if indexDB != nil {
indexDB.Close()
}
if settings.Config.Server.CacheDirCleanup {
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
}
<-shutdownComplete // Ensure we don't exit prematurely
// Wait for the server to stop
<-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()
// setup disk cache
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
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, false)
} else {
fileutils.InitAssetFS(nil, false, settings.Env.IsDevMode)
}
// Start preview service
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
logger.Fatalf("Error starting preview service: %v", err)
}
fbhttp.StartHttp(ctx, store, shutdownComplete)
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
// Initialize PWA manifest after icons are generated
icons.InitializePWAManifest()
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
}
+205 -65
View File
@@ -4,105 +4,157 @@ import (
"fmt"
"os"
"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")
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
if user.Username == adminUser && adminPass != "" && passwordEnabled {
if updateSidebarLinks(user, scopesChanged) {
changedFields = append(changedFields, "sidebarLinks")
}
if updateTokens(user) {
changedFields = append(changedFields, "tokens", "version")
}
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
}
@@ -116,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
@@ -162,7 +214,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = 1
user.Version = users.PermMigrationVersion
if updateUser {
createBackup = true
}
@@ -186,3 +238,91 @@ func updatePreviewSettings(user *users.User) bool {
}
return false
}
// 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 !changed {
return false
}
user.SidebarLinks = links
return true
}
func updateTokens(user *users.User) bool {
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
token.Name = name
users.StoreToken(user.Tokens, token)
}
}
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 -65
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)
})
}
@@ -156,7 +159,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
sourceA = settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
DefaultEnabled: true,
DefaultUserScope: "/defaultA",
CreateUserDir: true,
}}
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
@@ -172,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)
})
}
-135
View File
@@ -1,135 +0,0 @@
package settings
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/go-logger/logger"
)
// userAgentTransport wraps an http.RoundTripper to add a User-Agent header
type userAgentTransport struct {
base http.RoundTripper
userAgent string
}
// RoundTrip implements the http.RoundTripper interface
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
reqCopy := req.Clone(req.Context())
// Set User-Agent header if not already set
if reqCopy.Header.Get("User-Agent") == "" {
reqCopy.Header.Set("User-Agent", t.userAgent)
}
return t.base.RoundTrip(reqCopy)
}
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
Methods LoginMethods `json:"methods"`
Key string `json:"key"` // secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
AdminUsername string `json:"adminUsername"` // secret: the username of the admin user. If not set, the default is "admin".
AdminPassword string `json:"adminPassword"` // secret: the password of the admin user. If not set, the default is "admin".
TotpSecret string `json:"totpSecret"` // secret: secret used to encrypt TOTP secrets
AuthMethods []string `json:"-"`
}
type LoginMethods struct {
ProxyAuth ProxyAuthConfig `json:"proxy" validate:"omitempty"`
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
}
type PasswordAuthConfig struct {
Enabled bool `json:"enabled"`
MinLength int `json:"minLength" validate:"omitempty"` // minimum pasword length required, default is 5.
Signup bool `json:"signup" validate:"omitempty"` // allow signups on login page if enabled -- not secure.
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
EnforcedOtp bool `json:"enforcedOtp"` // if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"` // create user if not exists
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
}
type Recaptcha struct {
Host string `json:"host" validate:"required"`
Key string `json:"key" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
// OpenID OAuth2.0
type OidcConfig struct {
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
ClientID string `json:"clientId"` // secret: client id of the OIDC application
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
CreateUser bool `json:"createUser"` // create user if not exists
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges.
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
Provider *oidc.Provider `json:"-"` // OIDC provider
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
}
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
func validateOidcAuth() error {
oidcCfg := &Config.Auth.Methods.OidcAuth // Use a pointer to modify the original config
if oidcCfg.GroupsClaim == "" {
oidcCfg.GroupsClaim = "groups"
}
if oidcCfg.UserIdentifier == "" {
oidcCfg.UserIdentifier = "preferred_username"
}
if oidcCfg.Scopes == "" {
oidcCfg.Scopes = "openid email profile"
}
if !oidcCfg.Enabled {
return errors.New("OIDC is not enabled")
}
ctx := context.Background()
// Create a custom HTTP client with proper User-Agent to avoid being blocked by bot protection
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, // Respect HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables
}
// If disableVerifyTLS is true, disable TLS verification
if oidcCfg.DisableVerifyTLS {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
logger.Warning("OIDC TLS verification is disabled.")
}
// Create custom client with User-Agent header to bypass bot protection (like Cloudflare Bot Fight Mode)
customClient := &http.Client{
Transport: &userAgentTransport{
base: transport,
userAgent: fmt.Sprintf("FileBrowser Quantum - %s (OIDC Client)", version.Version),
},
}
// Use oidc.ClientContext to pass the custom client to the OIDC library
ctx = oidc.ClientContext(ctx, customClient)
provider, err := oidc.NewProvider(ctx, oidcCfg.IssuerUrl)
if err != nil {
return fmt.Errorf("url '%v' failed to create OIDC provider: %w", oidcCfg.IssuerUrl, err)
}
oidcCfg.Provider = provider
oidcCfg.Verifier = provider.Verifier(&oidc.Config{ClientID: oidcCfg.ClientID})
return nil
}
-817
View File
@@ -1,817 +0,0 @@
package settings
import (
"bytes"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
var Config Settings
const (
generatorPath = "/relative/or/absolute/path"
)
func Initialize(configFile string) {
err := loadConfigWithDefaults(configFile, false)
if err != nil {
logger.Error("unable to load config, waiting 5 seconds before exiting...")
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
logger.Fatal(err.Error())
}
setupLogging()
// setup logging first to ensure we log any errors
setupEnv()
err = ValidateConfig(Config)
if err != nil {
errmsg := "The provided config file failed validation. "
errmsg += "If you are seeing this on a config that worked before, "
errmsg += "then check the latest releases for breaking changes. "
errmsg += "visit https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example for more information."
logger.Error(errmsg)
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
logger.Fatal(err.Error())
}
setupFs()
setupServer()
setupAuth(false)
setupSources(false)
setupUrls()
setupFrontend(false)
setupMedia()
}
func setupServer() {
if Config.Server.ListenAddress == "" {
Config.Server.ListenAddress = "0.0.0.0"
}
}
func setupEnv() {
Env.IsPlaywright = os.Getenv("FILEBROWSER_PLAYWRIGHT_TEST") == "true"
if Env.IsPlaywright {
logger.Warning("Running in playwright test mode. This is not recommended for production.")
}
Env.IsDevMode = os.Getenv("FILEBROWSER_DEVMODE") == "true"
if Env.IsDevMode {
logger.Warning("Running in dev mode. This is not recommended for production.")
}
}
func setupFs() {
// Convert permission values (like 644, 755) to octal interpretation
filePermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateFilePermission, 8, 32)
if err != nil {
Config.Server.Filesystem.CreateFilePermission = "644"
filePermOctal, _ = strconv.ParseUint("644", 8, 32)
}
dirPermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateDirectoryPermission, 8, 32)
if err != nil {
Config.Server.Filesystem.CreateDirectoryPermission = "755"
dirPermOctal, _ = strconv.ParseUint("755", 8, 32)
}
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
// try writing to cache dir
absCacheDir, err := filepath.Abs(Config.Server.CacheDir)
if err != nil {
logger.Errorf("error getting absolute path for 'server.cacheDir: %v': %v", Config.Server.CacheDir, err)
}
cacheDir := filepath.Join(absCacheDir, "init_test")
err = os.MkdirAll(cacheDir, fileutils.PermDir)
if err != nil {
logger.Errorf("Unable to write to 'server.cacheDir: %v', please ensure the cache directory is writable: %v", absCacheDir, err)
}
}
func setupFrontend(generate bool) {
// Load login icon configuration at startup
loadLoginIcon()
if Config.Server.MinSearchLength == 0 {
Config.Server.MinSearchLength = 3
}
if !Config.Frontend.DisableDefaultLinks {
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: fmt.Sprintf("(%v)", version.Version),
Title: version.CommitSHA,
Url: "https://github.com/gtsteffaniak/filebrowser/releases/",
})
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: "Help",
Url: "help prompt",
})
}
if Config.Frontend.Description == "" {
Config.Frontend.Description = "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server"
}
Config.Frontend.Styling.LightBackground = FallbackColor(Config.Frontend.Styling.LightBackground, "#f5f5f5")
Config.Frontend.Styling.DarkBackground = FallbackColor(Config.Frontend.Styling.DarkBackground, "#141D24")
var err error
if Config.Frontend.Styling.CustomCSS != "" {
Config.Frontend.Styling.CustomCSSRaw, err = readCustomCSS(Config.Frontend.Styling.CustomCSS)
if err != nil {
logger.Warning(err.Error())
}
}
Config.Frontend.Styling.CustomThemeOptions = map[string]CustomTheme{}
if Config.Frontend.Styling.CustomThemes == nil {
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
}
for name, theme := range Config.Frontend.Styling.CustomThemes {
addCustomTheme(name, theme.Description, theme.CSS)
}
noThemes := len(Config.Frontend.Styling.CustomThemes) == 0
if noThemes {
addCustomTheme("default", "The default theme", "")
// check if file exists
if _, err := os.Stat("reduce-rounded-corners.css"); err == nil {
addCustomTheme("alternative", "Reduce rounded corners", "reduce-rounded-corners.css")
if generate {
Config.Frontend.Styling.CustomThemes["alternative"] = CustomTheme{
Description: "Reduce rounded corners",
CSS: "reduce-rounded-corners.css",
}
}
}
}
_, ok := Config.Frontend.Styling.CustomThemes["default"]
if !ok {
addCustomTheme("default", "The default theme", "")
}
// Load custom favicon if configured
loadCustomFavicon()
}
func setupMedia() {
// If VideoPreview is not initialized, initialize with all types enabled
if Config.Integrations.Media.Convert.VideoPreview == nil {
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
}
// If VideoPreview map is empty, it means user didn't configure any video preview settings
// In this case, enable all by default
if len(Config.Integrations.Media.Convert.VideoPreview) == 0 {
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
}
// User has explicitly configured some video preview settings
// Start with all enabled, then apply user overrides
userConfig := make(map[VideoPreviewType]bool)
for k, v := range Config.Integrations.Media.Convert.VideoPreview {
userConfig[k] = v
}
// Reset to defaults (all enabled)
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
// Apply user overrides (only for explicitly set values)
for k, v := range userConfig {
Config.Integrations.Media.Convert.VideoPreview[k] = v
}
}
func setupSources(generate bool) {
if len(Config.Server.Sources) == 0 {
logger.Fatal("There are no `server.sources` configured. If you have `server.root` configured, please update the config and add at least one `server.sources` with a `path` configured.")
} else {
for k, source := range Config.Server.Sources {
if source.Config.Disabled {
continue
}
realPath, err := filepath.Abs(source.Path)
if err != nil {
logger.Fatalf("error getting real path for source %v: %v", source.Path, err)
}
exists := utils.CheckPathExists(realPath)
if !exists {
logger.Warningf("source path %v is currently not available", realPath)
}
name := filepath.Base(realPath)
if name == "\\" {
name = strings.Split(realPath, ":")[0]
}
if generate {
source.Path = generatorPath // use placeholder path
} else {
source.Path = realPath // use absolute path
}
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
if ok {
source.Name = name + fmt.Sprintf("-%v", k)
} else {
source.Name = name
}
}
modifyExcludeInclude(source)
setConditionals(source)
if source.Config.DefaultUserScope == "" {
source.Config.DefaultUserScope = "/"
}
Config.Server.SourceMap[source.Path] = source
Config.Server.NameToSource[source.Name] = source
}
}
// clean up the in memory source list to be accurate and unique
sourceList := []*Source{}
defaultScopes := []users.SourceScope{}
allSourceNames := []string{}
if len(Config.Server.Sources) == 1 {
Config.Server.Sources[0].Config.DefaultEnabled = true
}
for _, sourcePathOnly := range Config.Server.Sources {
absPath := sourcePathOnly.Path
if generate {
// When generating, skip path validation and use the already-set path
absPath = sourcePathOnly.Path
}
source, ok := Config.Server.SourceMap[absPath]
if ok && !slices.Contains(allSourceNames, source.Name) {
sourceList = append(sourceList, source)
if source.Config.DefaultEnabled {
defaultScopes = append(defaultScopes, users.SourceScope{
Name: source.Path,
Scope: source.Config.DefaultUserScope,
})
}
allSourceNames = append(allSourceNames, source.Name)
} else {
logger.Warningf("skipping source: %v", sourcePathOnly.Path)
}
}
Config.UserDefaults.DefaultScopes = defaultScopes
Config.Server.Sources = sourceList
}
func setupUrls() {
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Server.BaseURL = "/"
} else {
Config.Server.BaseURL = "/" + baseurl + "/"
}
if Config.Server.BaseURL != "/" {
if Config.Server.ExternalUrl != "" {
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, "/") + "/"
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, Config.Server.BaseURL)
}
if Config.Server.InternalUrl != "" {
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, "/") + "/"
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, Config.Server.BaseURL)
}
}
Config.Integrations.OnlyOffice.Url = strings.Trim(Config.Integrations.OnlyOffice.Url, "/")
Config.Integrations.OnlyOffice.InternalUrl = strings.Trim(Config.Integrations.OnlyOffice.InternalUrl, "/")
}
func setupAuth(generate bool) {
if generate {
Config.Auth.AdminPassword = "admin"
}
if Config.Auth.Methods.PasswordAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
if Config.Auth.Methods.ProxyAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
}
if Config.Auth.Methods.OidcAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
}
if Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
Config.Auth.AuthMethods = []string{"disabled"}
}
if Config.Auth.Methods.OidcAuth.Enabled || generate {
err := validateOidcAuth()
if err != nil && !generate {
logger.Fatalf("Error validating OIDC auth: %v", err)
}
logger.Info("OIDC Auth configured successfully")
}
// use password auth as default if no auth methods are set
if len(Config.Auth.AuthMethods) == 0 {
Config.Auth.Methods.PasswordAuth.Enabled = true
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
Config.UserDefaults.LoginMethod = Config.Auth.AuthMethods[0]
}
func setupLogging() {
if len(Config.Server.Logging) == 0 {
Config.Server.Logging = []LogConfig{
{
Output: "stdout",
},
}
}
for _, logConfig := range Config.Server.Logging {
// Enable debug logging automatically in dev mode
levels := logConfig.Levels
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
levels = "info|warning|error|debug"
}
logConfig := logger.JsonConfig{
Levels: levels,
ApiLevels: logConfig.ApiLevels,
Output: logConfig.Output,
Utc: logConfig.Utc,
NoColors: logConfig.NoColors,
Json: logConfig.Json,
Structured: false,
}
err := logger.EnableCompatibilityMode(logConfig)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
}
}
func loadConfigWithDefaults(configFile string, generate bool) error {
Config = setDefaults(generate)
// Check if config file exists
if _, err := os.Stat(configFile); err != nil {
if configFile != "" {
logger.Errorf("could not open config file '%v', using default settings.", configFile)
}
Config.Server.Sources = []*Source{
{
Path: ".",
},
}
loadEnvConfig()
return nil
}
// Try multi-file config first (combine all YAML files in the directory)
combinedYAML, err := combineYAMLFiles(configFile)
if err != nil {
return fmt.Errorf("failed to combine YAML files: %v", err)
}
// First pass: Unmarshal into a generic map to resolve all anchors and aliases
// This allows YAML anchors defined in auxiliary files to be properly merged
var rawConfig map[string]interface{}
err = yaml.Unmarshal(combinedYAML, &rawConfig)
if err != nil {
return fmt.Errorf("error parsing YAML data: %v", err)
}
// Filter to only keep valid top-level Settings struct fields
// This removes anchor definitions that are just templates (e.g., "test_server: &test_server")
validFields := map[string]bool{
"server": true,
"auth": true,
"integrations": true,
"frontend": true,
"userDefaults": true,
}
filteredConfig := make(map[string]interface{})
for key, value := range rawConfig {
if validFields[key] {
filteredConfig[key] = value
}
}
// Marshal the filtered config back to YAML
filteredYAML, err := yaml.Marshal(filteredConfig)
if err != nil {
return fmt.Errorf("error re-marshaling filtered YAML: %v", err)
}
// Second pass: Decode with strict validation (disallow unknown fields within valid sections)
decoder := yaml.NewDecoder(bytes.NewReader(filteredYAML), yaml.DisallowUnknownField())
err = decoder.Decode(&Config)
if err != nil {
return fmt.Errorf("error unmarshaling YAML data: %v", err)
}
loadEnvConfig()
return nil
}
func ValidateConfig(config Settings) error {
validate := validator.New()
// Register custom validator for file permissions
err := validate.RegisterValidation("file_permission", validateFilePermission)
if err != nil {
return fmt.Errorf("could not register file_permission validator: %v", err)
}
err = validate.Struct(Config)
if err != nil {
return fmt.Errorf("could not validate config: %v", err)
}
return nil
}
// validateFilePermission validates that a string is a valid Unix octal file permission (3-4 digits, 0-7)
func validateFilePermission(fl validator.FieldLevel) bool {
value := fl.Field().String()
// Must be 3 or 4 characters long
if len(value) < 3 || len(value) > 4 {
return false
}
// All characters must be octal digits (0-7)
for _, char := range value {
if char < '0' || char > '7' {
return false
}
}
return true
}
func loadEnvConfig() {
adminPassword, ok := os.LookupEnv("FILEBROWSER_ADMIN_PASSWORD")
if ok {
logger.Info("Using admin password from FILEBROWSER_ADMIN_PASSWORD environment variable")
Config.Auth.AdminPassword = adminPassword
}
officeSecret, ok := os.LookupEnv("FILEBROWSER_ONLYOFFICE_SECRET")
if ok {
logger.Info("Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable")
Config.Integrations.OnlyOffice.Secret = officeSecret
}
ffmpegPath, ok := os.LookupEnv("FILEBROWSER_FFMPEG_PATH")
if ok {
Config.Integrations.Media.FfmpegPath = ffmpegPath
}
oidcClientId := os.Getenv("FILEBROWSER_OIDC_CLIENT_ID")
if oidcClientId != "" {
Config.Auth.Methods.OidcAuth.ClientID = oidcClientId
logger.Info("Using OIDC Client ID from FILEBROWSER_OIDC_CLIENT_ID environment variable")
}
oidcClientSecret := os.Getenv("FILEBROWSER_OIDC_CLIENT_SECRET")
if oidcClientSecret != "" {
Config.Auth.Methods.OidcAuth.ClientSecret = oidcClientSecret
logger.Info("Using OIDC Client Secret from FILEBROWSER_OIDC_CLIENT_SECRET environment variable")
}
jwtTokenSecret := os.Getenv("FILEBROWSER_JWT_TOKEN_SECRET")
if jwtTokenSecret != "" {
Config.Auth.Key = jwtTokenSecret
logger.Info("Using JWT Token Secret from FILEBROWSER_JWT_TOKEN_SECRET environment variable")
}
totpSecret := os.Getenv("FILEBROWSER_TOTP_SECRET")
if totpSecret != "" {
Config.Auth.TotpSecret = totpSecret
logger.Info("Using TOTP Secret from FILEBROWSER_TOTP_SECRET environment variable")
}
recaptchaSecret := os.Getenv("FILEBROWSER_RECAPTCHA_SECRET")
if recaptchaSecret != "" {
Config.Auth.Methods.PasswordAuth.Recaptcha.Secret = recaptchaSecret
logger.Info("Using ReCaptcha Secret from FILEBROWSER_RECAPTCHA_SECRET environment variable")
}
}
func setDefaults(generate bool) Settings {
// get number of CPUs available
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
cpus := runtime.NumCPU()
if cpus > 0 && !generate {
numCpus = cpus
}
database := os.Getenv("FILEBROWSER_DATABASE")
if database == "" {
database = "database.db"
} else {
// check if database file exists
if _, err := os.Stat(database); os.IsNotExist(err) {
database = "database.db"
}
}
if _, err := os.Stat(database); os.IsNotExist(err) {
logger.Warning("database file could not be found. If this is unexpected, the default path is `./database.db`, but it can be configured in the config file under `server.database`.")
}
s := Settings{
Server: Server{
Port: 80,
NumImageProcessors: numCpus,
BaseURL: "",
Database: database,
SourceMap: map[string]*Source{},
NameToSource: map[string]*Source{},
MaxArchiveSizeGB: 50,
CacheDir: "tmp",
CacheDirCleanup: boolPtr(true),
Filesystem: Filesystem{
CreateFilePermission: "644",
CreateDirectoryPermission: "755",
},
},
Auth: Auth{
AdminUsername: "admin",
TokenExpirationHours: 2,
Methods: LoginMethods{
PasswordAuth: PasswordAuthConfig{
Enabled: true,
MinLength: 5,
Signup: false,
},
},
},
Frontend: Frontend{
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: UserDefaultsPermissions{
Modify: false,
Share: false,
Admin: false,
Api: false,
Download: boolPtr(true), // defaults to true
},
Preview: UserDefaultsPreview{
HighQuality: boolPtr(true),
Image: boolPtr(true),
Video: boolPtr(true),
MotionVideoPreview: boolPtr(true),
Office: boolPtr(true),
PopUp: boolPtr(true),
AutoplayMedia: boolPtr(true),
Folder: boolPtr(true),
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
ChunkSize: 10, // 10MB
},
},
}
// Initialize ImagePreview map with all supported types set to false by default
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]bool)
for _, t := range AllImagePreviewTypes {
s.Integrations.Media.Convert.ImagePreview[t] = false
}
// Initialize VideoPreview map with all supported types set to true by default
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
s.Integrations.Media.Convert.VideoPreview[t] = true
}
return s
}
// validateCustomImage validates a custom image file path and returns the absolute path or error
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
// Get absolute path
absolutePath, err = filepath.Abs(configPath)
if err != nil {
return "", fmt.Errorf("could not resolve path: %w", err)
}
// Check if file exists
stat, err := os.Stat(absolutePath)
if err != nil {
return "", fmt.Errorf("could not access file: %w", err)
}
// Check file size
if stat.Size() > maxSize {
return "", fmt.Errorf("file too large (%d bytes), maximum allowed is %d bytes", stat.Size(), maxSize)
}
// Validate file format
ext := strings.ToLower(filepath.Ext(absolutePath))
validFormat := false
for _, format := range allowedFormats {
if ext == format {
validFormat = true
break
}
}
if !validFormat {
return "", fmt.Errorf("unsupported format '%s', supported formats: %v", ext, allowedFormats)
}
return absolutePath, nil
}
func loadCustomFavicon() {
const imageName = "favicon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".ico", ".png", ".svg"}
// Set default embedded favicon path
Env.FaviconEmbeddedPath = "img/icons/favicon.ico"
// Check if a custom favicon path is configured
if Config.Frontend.Favicon == "" {
Env.FaviconPath = Env.FaviconEmbeddedPath
Env.FaviconIsCustom = false
return
}
// Validate custom favicon
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
if err != nil {
logger.Warningf("Custom favicon validation failed: %v, using default", err)
Config.Frontend.Favicon = ""
Env.FaviconPath = Env.FaviconEmbeddedPath
Env.FaviconIsCustom = false
return
}
// Update to validated path and mark as custom
Config.Frontend.Favicon = validatedPath
Env.FaviconPath = validatedPath
Env.FaviconIsCustom = true
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
}
func loadLoginIcon() {
const imageName = "login icon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
// Set default embedded icon path based on dark mode preference
isDarkMode := Config.UserDefaults.DarkMode != nil && *Config.UserDefaults.DarkMode
if isDarkMode {
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg" // Dark mode: dark background
} else {
Env.LoginIconEmbeddedPath = "img/icons/favicon-light.svg" // Light mode: light background
}
// Check if a custom login icon path is configured
if Config.Frontend.LoginIcon == "" {
Env.LoginIconPath = Env.LoginIconEmbeddedPath
Env.LoginIconIsCustom = false
return
}
// Validate custom login icon
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
if err != nil {
logger.Warningf("Custom login icon validation failed: %v, using default", err)
Env.LoginIconPath = Env.LoginIconEmbeddedPath
Env.LoginIconIsCustom = false
return
}
// Update to validated path and mark as custom
Env.LoginIconPath = validatedPath
Env.LoginIconIsCustom = true
logger.Infof("Using custom login icon: %s", Env.LoginIconPath)
}
// setConditionalsMap builds optimized map structures from conditional rules for O(1) lookups
func setConditionals(config *Source) {
// backwards compatibility
if config.Config.Conditionals.Hidden {
logger.Warning("conditionals.hidden is deprecated, use conditionals.ignoreHidden instead")
config.Config.Conditionals.IgnoreHidden = true
}
rules := config.Config.Conditionals.ItemRules
// Initialize the maps structure (only exact match maps for Names)
resolved := &ResolvedConditionalsConfig{
FileNames: make(map[string]ConditionalIndexConfig),
FolderNames: make(map[string]ConditionalIndexConfig),
FilePaths: make(map[string]ConditionalIndexConfig),
FolderPaths: make(map[string]ConditionalIndexConfig),
FileEndsWith: make([]ConditionalIndexConfig, 0),
FolderEndsWith: make([]ConditionalIndexConfig, 0),
FileStartsWith: make([]ConditionalIndexConfig, 0),
FolderStartsWith: make([]ConditionalIndexConfig, 0),
NeverWatchPaths: make(map[string]struct{}),
IncludeRootItems: make(map[string]struct{}),
}
for _, rule := range rules {
if rule.FileEndsWith != "" {
resolved.FileEndsWith = append(resolved.FileEndsWith, rule)
}
if rule.FolderEndsWith != "" {
resolved.FolderEndsWith = append(resolved.FolderEndsWith, rule)
}
if rule.FileStartsWith != "" {
resolved.FileStartsWith = append(resolved.FileStartsWith, rule)
}
if rule.FolderStartsWith != "" {
resolved.FolderStartsWith = append(resolved.FolderStartsWith, rule)
}
if rule.FilePath != "" {
resolved.FilePaths[rule.FilePath] = rule
}
if rule.FolderPath != "" {
resolved.FolderPaths[rule.FolderPath] = rule
}
if rule.NeverWatchPath != "" {
resolved.NeverWatchPaths[rule.NeverWatchPath] = struct{}{}
}
if rule.IncludeRootItem != "" {
resolved.IncludeRootItems[rule.IncludeRootItem] = struct{}{}
}
if rule.FileNames != "" {
resolved.FileNames[rule.FileNames] = rule
}
if rule.FolderNames != "" {
resolved.FolderNames[rule.FolderNames] = rule
}
if rule.FileName != "" {
resolved.FileNames[rule.FileName] = rule
}
if rule.FolderName != "" {
resolved.FolderNames[rule.FolderName] = rule
}
}
config.Config.ResolvedConditionals = resolved
}
func modifyExcludeInclude(config *Source) {
// Helper to normalize a full path value (FilePaths, FolderPaths)
// These always start with "/" and match against full index paths
normalizeFullPath := func(value string, checkExists bool) string {
if value == "" {
return ""
}
normalized := "/" + strings.Trim(value, "/")
// check if file/folder exists
if checkExists {
realPath, err := filepath.Abs(config.Path + normalized)
if err != nil {
logger.Warningf("could not get absolute path for %v: %v", normalized, err)
return normalized
}
if _, err := os.Stat(realPath); os.IsNotExist(err) {
logger.Warningf("configured exclude/include path %v does not exist", realPath)
}
}
return normalized
}
// Helper to normalize a name-based value (FileNames, FolderNames, StartsWith, EndsWith)
// These match against baseName, so no leading slash
normalizeName := func(value string) string {
// Just trim slashes - names shouldn't have slashes
return strings.Trim(value, "/")
}
// Normalize []ConditionalIndexConfig slices for full paths
for i, rule := range config.Config.Conditionals.ItemRules {
// normalize full paths
config.Config.Conditionals.ItemRules[i].FilePath = normalizeFullPath(rule.FilePath, true)
config.Config.Conditionals.ItemRules[i].FolderPath = normalizeFullPath(rule.FolderPath, true)
config.Config.Conditionals.ItemRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true)
config.Config.Conditionals.ItemRules[i].IncludeRootItem = normalizeFullPath(rule.IncludeRootItem, true)
// normalize names
config.Config.Conditionals.ItemRules[i].FileNames = normalizeName(rule.FileNames)
config.Config.Conditionals.ItemRules[i].FolderNames = normalizeName(rule.FolderNames)
config.Config.Conditionals.ItemRules[i].FileName = normalizeName(rule.FileName)
config.Config.Conditionals.ItemRules[i].FolderName = normalizeName(rule.FolderName)
}
}
@@ -1,13 +0,0 @@
package settings
func CanConvertImage(ext string) bool {
return MediaEnabled() && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
}
func CanConvertVideo(ext string) bool {
return MediaEnabled() && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
}
func MediaEnabled() bool {
return Env.FFmpegPath != "" && Env.FFprobePath != ""
}
Loaded 100 of 1292 files, more files were not shown because too many files have changed in this diff. Show more