Compare commits

...
Author SHA1 Message Date
Graham Steffaniak 5597b28f10 update anonymous user access 2026-05-13 20:03:23 -05:00
Graham Steffaniak 6bfc397419 Fix inline svg attack (#2376) 2026-05-01 13:49:55 -05:00
Graham Steffaniak 112740bdd4 security bugfix (#2374) 2026-05-01 13:38:44 -05:00
Graham Steffaniak 9117fb2b5f fix context menu (#2373) 2026-05-01 10:05:04 -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
399 changed files with 43948 additions and 30999 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": "20"
},
"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"
]
}
}
}
+2
View File
@@ -1,4 +1,6 @@
backend/**.db
**.crt
**.key
backend/tmp
backend/dist
backend/backend*
+6
View File
@@ -80,6 +80,12 @@ 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
steps:
- name: Checkout
uses: actions/checkout@v4
+11 -2
View File
@@ -14,7 +14,7 @@ jobs:
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
steps:
@@ -56,4 +56,13 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run i18n:check
run: npm i && npm run i18n:check
check-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- run: make generate-docs
- run: git diff --exit-code
+1 -1
View File
@@ -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:
+3
View File
@@ -1,4 +1,6 @@
*.db
*.db.old
*.sqlite
*.bak
*.log
*.mjs
@@ -18,6 +20,7 @@ frontend/tsconfig.tsbuildinfo
/frontend/test-results
/frontend/playwright-files
/frontend/screenshots
/frontend/generated
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/backend/vendor
+344
View File
@@ -2,6 +2,350 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.3.9
**Security**:
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
**BugFixes**:
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
## v1.3.8
**BugFixes**:
- Quick download icon style after icon change.
- Missing error popup for resource creatoin actions (upload/create)
- EnforcedOtp login failure until restart (#2330)
- Thumbnails for Folders only display sporadically (#2353)
- Unwanted user scope change for users with non-default scopes (#2347)
- Fix sidebar source info totals (#2321) (#2322) (#982)
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
## v1.3.7
**Notes**:
- creating/deleting password-based user requires reauthentication (#2112)
**BugFixes**:
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
- No Loginfields shown if password authentification is set to false (#2331)
## v1.3.6
**BugFixes**:
- file watcher not working for files and not respecting line count (#2314)
- "source" type link shows up multiple times when adding sidebar links if user has multiple sources.
- JWT ES256 results in error (#2188)
- single file shares not loading editor text content (#2122)
## v1.3.5
**Notes**:
- any password change or admin user property change for a password user requires reauthentication (#2112)
**BugFixes**:
- PWA icon fixes (#2292)
- deny-rule'd folders visible in directory listings (regression from v1.2.4-stable) (#2295)
- OTP and password requirement fixes (#2112) (#2263)
- CLI not picking up config properly
- Storage usage numbers on first load adjusted.
- Some items in userDefaults are not setting defaults for new users properly #2278
- Fix copy to clipboard and simplify code (#2281) (#2274)
- Fix search shortcut `/` and pdf previews (#2307)
- Special permissions bit not set when declared in createDirectoryPermission (#2283)
- Execute permission altered after editing sh script with FB Quantum (#2309)
- Source is red but working (#2289)
## v1.3.4
**New Features**:
- Epub placementg url anchors to bookmark a specific location on the doc.
- Add Requirement for Current Password When Changing Account Password (#2112)
- Copy file path to clipborad through right click (#2204)
**Notes**:
- user scope editing has path picker and filesystem validation
**BugFixes**:
- Swiping media files doesn't respect Playback Queue (#2243)
- Audio metadata and album thumbnails doesn't work (#2236)
- Search spinner wouldn't show up sometimes while searching
- The default landing page source should always be the first source in the sidebar, that wasn't always respected.
## v1.3.3
**New Features**:
- Copy/paste files and folders (`CTRL+C`/`CTRL+V`) from other apps (win explorer, thunar, finder, etc) to upload them directly (#2197)
- Caption font size can be adjusted in video settings
- Ability to adjust the startup check method for sql database via `server.startupIntegrityCheck` (#2221)
- Status bar for editor and markdown viewer (#2226)
**Notes**:
- `.fbx` 3d model files added to supported previews.
- removed upx compression on docker image (#2193)
- videos double tap to fast-forward and rewind added.
- Adjust Image album swipe behavior (#2068)
- swipe animation for next/previous
- swipe down on image to close and go to parent folder.
- supports videos
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
- The Icons in the UI were updated! (#2203)
- More supported icons in the Icon Picker tool.
- More file types have new icons across all the listings (such as `.md`, `.apk`, etc).
- `.fbx` 3d model files added to supported previews.
- removed upx compression on docker image (#2193)
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
- deprecated `source.config.CreateUserDir`, now its always `true`. If a user directory doesn't exist it will get created empty.
- `CTRL`+`Mouse Wheel` shortcut to change listing size. Also for change font size in editor (#2227)
**BugFixes**:
- Archives don't preserve file metadata (#2063)
- Chunked upload setting fails: "0" does not disable chunking as expected (#2202)
- Incorrect Chinese labels for Upload controls in Quantum UI (#2191)
- Chunked download stops after first chunk, add message explaining (#2074)
- Esc to Cancel and Enter to confirm popup (#2079)
- Language-tagged subtitle files not detected for videos (#2199)
- Unable to use proxy auth since 1.3.0-beta (#2173)
- Updating setting on a LDAP user turns the "Login Method" to password. Thus preventing further logins. (#2179)
## v1.3.2-beta
**Security**:
- Patched Username Enumeration via Authentication Timing Side-Channel GHSA-7789-65hx-f26w
**New Features**:
- Option in settings `userDefaults.preferEditorForMarkdown` to prefer editor first for Markdown files (#2136)
- Copy to clipboard button for code blocks in Markdown Viewer (#2160)
- Add "Last modified" filter in search dialog (#2157)
**Notes**:
- docs preview for text and pdf has a 2 second timeout. If it hangs for whatever reason, the maximum time would be 2 seconds. (#2105) (#2114)
- Downloading multiple files streams the archive creation rather than using cacheDir -- thanks @janakoram (#2125) (#2130)
- `server.maxArchiveSizeGB` now defaults to 20 (GB) and only applies to archive/unarchive actions (not downloads).
- browser download progress bar will no longer show for archive downloads. this is the main drawback to the streaming approach.
- should allow for much higher parallel download support and lower cleanup maintenenance.
- [docker] ffmpeg version upgraded to 8.1
- remote ip in logs now prefers `X-Forwarded-For` if it exists, then `X-Real-IP`, then lastly the standard RemoteAddr. Useful when running behind a proxy to log the public IP of each request. (#2110)
- changed loading spinner style to be more compatible with safari browsers.
**BugFixes**:
- Wrong username in share settings (#2147) (#2148)
- [OnlyOffice] Error when saving a file under a user scope #2133
- Cannot edit shared file in OnlyOffice #2143
- PWA install button disappeared (#2086)
- Deleting a root folder was possible #2128
- PUT resource api errors if action against a folder (#2153)
- LDAP authentication issue if a password caontains @ symbol (#2154)
- Share banner seems to be not working for custom urls (#2120)
## v1.3.1-beta
**Security**:
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse) GHSA-r633-fcgp-m532
- Patched Incomplete Remediation of CVE-2026-27611: Password-Protected Share Bypass via /public/api/share/info GHSA-525j-95gf-766f
**New Features**:
- More user options for settings (#2072) (#2067):
- Option to disable thumbnails `userDefaults.preview.audio` and `userDefaults.preview.models` for Audio and 3D Models.
- Option to disable files in the Tree navigation `userDefaults.hideFilesInTree`
- Option to disable source files deletion when creating/extracting archives. `userDefaults.deleteAfterArchive`
**Notes**:
- Share icon does not show in share listing or for shares for other users.
- File Size Analyzer tool max items increased from 100 to 200.
- changed symlink detection logic.
**BugFixes**:
- 405 response code error on Webdav in 1.3.0-beta (#2054)
- Motion Preview setting not saving when changed in profile settings.
- Context menu on tools not working.
## v1.3.0-beta
Note: a potentially breaking change for docker users: \the default user is now "filebrowser" 1000:1000 instead of root.
**New Features**:
- New Sidebar Features
- Sidebar tree navigation (#2006) (#350)
- Source usage to be customized to show os-reported values rather than calculated. This can be changed per source by editing the source link in the sidebar. (#1266) (#982)
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
- new api to archive/unarchive files on the server
- requires `create` user permissions
- archiving actions respect `server.maxArchiveSize`
- Added share icon to items that are shared (#1420)
- Authentication enhancements
- LDAP login support with OIDC feature parity. (#591)
- userGroup for OIDC and LDAP, only users in a group will get access. (#1964)
- Add JWT token authentication support (#1364)
- Enhanced thumbnail and item previews
- Added ability to show "motion preview" for folders with multiple child items that have previews. cycles through the first 4 images.
- support for reading embedded images from raw image or heic/heif files (#215)
- reorganized and simplified thumbnail settings in profile settings (#1968)
- removed `highQuality` thumbnail option which only affected gallery view. Now its always enabled.
- improved caching for unsupported images, the same file won't be attempted again with the same modtime.
- supports 3d model previews.
- FileWatcher also supports watching directories
- Support previews for 3D model files (STL, OBJ, 3MF, etc.) (#1273)
- supported formats via threejs: GLTF, GLB, OBJ, STL, PLY, DAE (Collada), 3MF, 3DS, USDZ, USD, USDA, USDC, AMF, VRML, WRL, VTK, VTP, PCD, XYZ, VOX, KMZ
- supports animations (for formats that contain them)
- supports embedded textures, external neighboring file textures, or textures in `/textures` subdirectory
- Enhanced prompts
- All prompts have a taskbar with a close button
- Prompts can be freely moved by dragging taskbar
- Prompt styling has been updated
- Clicking outside of prompts no longer automatically closes them.
- add webdav support (#209) -- thanks to @reddac for (#1764)
- see docs on how to use
- requires api an un-customized api token as password
- respects access rules
- requires download permission to view and modify/create/delete permission to modify.
**Notes**:
- Docker images default to `filebrowser` user instead of root
- reorganized api routes
- consolidated tags for swagger to be more accurately grouped
- tools are all behind `/api/tools` routes
- `/api/raw` is deprecated (but functional). The `/api/resources/download` route will be used instead.
- `/api/preview` has been removed and replaced with `/api/resources/preview`
- `/api/onlyoffice` have been replaced with `/api/office`
- `/api/shares` has been moved to `/api/share/list`
- `/api/auth/tokens` has been moved to `/api/auth/token/list` and `/api/auth/token` added to get specific token info
- `PUT /api/token` has been moved to `POST /api/token`
- `/public/api/shareinfo` has been moved to `/public/api/share/info`
- `POST /resources/bulk/delete` api has been moved to `DELETE /resources/bulk` (#1984)
**BugFixes**:
- Long folder names get cut off at top navigation bar (#1934)
## v1.2.2-stable
**Security**:
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse)
**New Features**:
- "Divider" option in sidebar links to add a text or divider between links ()
- shares offer a "go to source Location" sidebar link and button when editing a share.
**Notes**:
- Share edit/delete permissions are scoped to the user's shares rather than global (#2050)
**BugFixes**:
- fixed the requirement that the database path needed to be set in the config file, now it loads `FILEBROWSER_DATABASE` value by default, fallback to config file property.
- Error downloading zipped directory: no such file or directory (users with scope) #2015
## v1.2.1-stable
**BugFixes**:
- startup icon generation order issue (#2019)
- login button on shares not showing (#2028)
- removed file watcher link in context menu for shares (#2025)
- Direct download link for single-file shares generates incorrect URL (#2026)
- Deleting files causes error V1.2.0 #2039
## v1.2.7-beta
**BugFixes**:
- head > title > infinitely duplicating titles (#2016)
- Proxy auth not working with Nginx, stuck at logo (#2013)
## v1.2.6-beta
**Security**:
- resolves https://github.com/gtsteffaniak/filebrowser/security/advisories/GHSA-8vrh-3pm2-v4v6
## v1.2.5-beta
**Notes**:
- Dependency Updates for frontend and backend packages
- Upgrade to go 1.26
- file list prompt interaction behavior, single-click always selects, double-click navigates. (#1911)
**BugFixes**:
- .ssa and .ass subtitle files get their styling removed during conversion (#1933)
- custom icons issue
- new database message for noauth (#1935)
## v1.2.4-beta
**Notes**:
- Changed `/api/media/subtitles` api endpoint to better support subtitles.
- Correcting some errors in French language (#1947)
- add Dutch `nl.json` for frontend i18n -- thanks @Stephan-P (#1957)
- share banner and icon images automatically serve a scaled down 1024x1024
**BugFixes**:
- Can't access directories with space (and possibly other special characters) when browsing a public link share (#1956)
- External ASS subtitles are not usable in web preview (#1933)
- Filename blocking rename/move (#1950)
- enforcedOtp: true blocks all user settings updates with HTTP 400 (#1962)
- Share favicon URL not showing up and Share description causing endless loading (#1911)
- folder sizes not updated for changes to filesystem outside of filebrowser (#1974)
## v1.2.3-beta
**Notes**:
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
**BugFixes**:
- fix stuck motion preview popup window issue
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
- share delete with user scope issue
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
- delete prompt icons show image previews
## v1.2.2-beta
**New Features**:
- Resizable sidebar (#1896)
- OIDC Authentication: Change Button Text via `frontend.oidcLoginButtonText` (#1708)
- improved favicon processing (#1899)
- supports more formats
- supports larger images
- automatcially generates multiple favicon sizes on startup for non-svg images. Custom svg favicons need a companion *.png to exist broad compatibilty
- Enhanced media playback: Ability to control the queue from your device's lock screen and notification panel - Will also show
metadata of the current playing media if available (#1917)
**Notes**:
- Better text file content detection (#1726)
- More url encoding changes for API which should make things more consistent. Open issues if you see path/source not found errors.
- adjustments to the startup behavior for sqlite index for reusing the previous database on startup
- CTRL + B disables sticky sidebar forever (#1869)
- added context menu back to duplicate finder
- Sharing a link for uploads - folder/file access and UX polishing (#1902)
- improved listing view and scrolling performance
- improved image viewer which will utilize recent thumbnails as a placeholder when loading the full image.
- Small reorganization of "Share" settings to make the popup clear (#1826)
**BugFixes**:
- Added docker default `FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"` back -- mistakenly removed. see https://filebrowserquantum.com/en/docs/getting-started/config/#3-default-locations. (#1891)
- Fix brand text in login screen (#1898)
- Sidebar links cointains urls with 2x repeated source name (#1847)
- 2 factor auth getting overwritten if you edit the auth from admin (#1819)
- New created user's files are listing error, Probably related with language setting. (#1565)
- thumbnail generation cpu/memory and concurrency bug.
- added 75MB filesize limit for image previews
- optimized concurrency for large vs small images
## v1.2.1-beta
**New Features**:
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
**Notes**:
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850)
- updated download api to use repeated `file` query param for path instead of `files` with comma. See swagger for details (#1881)
**BugFixes**:
- OnlyOffice stopped working after 1.2.0 upgrade due to download api changes
- Search result link to wrong path when search context other than / (#1863)
- File actions doesn't work if using user scopes (#1879)
- Drop items into folders doesn't work (#1860)
- duplicate finder context menu (#1862)
- File list in previews doesn't show the parent items (#1861)
## v1.2.0-beta
This is a major version update with many code changes which could cause unexpected behavior. Upgrades should proceed with caution and report any undesriable behavior by opening an issue on github. The index is now fully in sqlite database - see [Indexing Overview](https://filebrowserquantum.com/en/docs/features/indexing/) for more information.
+33 -43
View File
@@ -17,7 +17,7 @@
## Pinned
:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
@@ -25,7 +25,7 @@
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,20 +35,20 @@ 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. ✅ Ultra-efficient Sqlite [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing)
- 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**
- Better thumbnail support including **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
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
@@ -59,20 +59,10 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
- 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:
@@ -104,45 +94,45 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
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 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
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 | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
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 | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
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: | ❌ | ✅ | ❌ | ❌ | ❌ |
+3 -5
View File
@@ -1,17 +1,15 @@
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
FROM gtstef/ffmpeg:8.1-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
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}'" \
-o filebrowser .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /app
@@ -27,6 +25,7 @@ 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_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
RUN adduser -D -s /bin/true -u 1000 filebrowser
@@ -43,7 +42,6 @@ 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 \
+2 -1
View File
@@ -1,4 +1,5 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps firefox
RUN npx playwright install --with-deps chromium
+3
View File
@@ -14,6 +14,9 @@ COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/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
+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 ./http/dist
COPY [ "./backend/http/embed/", "./http/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
+14
View File
@@ -0,0 +1,14 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
ENTRYPOINT [ "./filebrowser" ]
+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 ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+15
View File
@@ -0,0 +1,15 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
+3 -2
View File
@@ -18,5 +18,6 @@ COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser set rule -f /tests/playwright-files -p / -r user -v admin -allow -c config.yaml
RUN ./filebrowser set rule -f /tests/playwright-files -p /excluded -r user -v admin -c config.yaml
RUN ./filebrowser set rule -s access -p / -r user -v admin -allow -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
+2 -4
View File
@@ -3,13 +3,11 @@ ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
ENV GOEXPERIMENT=greenteagc
RUN apk update && apk add
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /app
@@ -23,6 +21,7 @@ RUN npm run build:docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
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
@@ -35,7 +34,6 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/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 \
+35 -1
View File
@@ -1,4 +1,19 @@
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-local
context: ../
nginx-proxy-auth:
image: nginx
container_name: nginx-proxy-auth
@@ -7,11 +22,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
+4 -1
View File
@@ -8,8 +8,11 @@ userDefaults:
image: true
popup: true
video: true
audio: true
models: true
office: true
highQuality: true
permissions:
download: true
create: true
create: true
share: true
+48
View File
@@ -0,0 +1,48 @@
server:
port: 8080
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: 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:
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
+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
},
},
],
});
+35
View File
@@ -0,0 +1,35 @@
server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
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:
showHidden: true
preview:
image: true
popup: true
video: true
audio: true
office: true
models: true
highQuality: true
permissions:
download: true
create: 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"] },
},
],
});
@@ -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,10 @@
server:
port: 8080
baseURL: "/files/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright"
config:
defaultEnabled: true
@@ -0,0 +1,65 @@
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/screenshots-setup.ts",
timeout: 5000,
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',
},
},
],
});
@@ -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,
+59 -4
View File
@@ -10,6 +10,9 @@ import (
"os"
"path/filepath"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Cache interface for caching operations
@@ -29,9 +32,61 @@ type FileCache struct {
}
}
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
return &FileCache{dir: dir}, nil
cacheDir := filepath.Join(dir, "diskcache")
// Migrate existing cache files from old structure (if any)
if err := migrateOldCacheStructure(dir, cacheDir); err != nil {
logger.Warningf("failed to migrate old cache structure: %v\n", err)
}
// Create the cache directory immediately with proper permissions
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
return &FileCache{dir: cacheDir}, nil
}
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
func migrateOldCacheStructure(oldDir, newDir string) error {
// Read the old cache directory
entries, err := os.ReadDir(oldDir)
if err != nil {
if os.IsNotExist(err) {
return nil // Directory doesn't exist yet, nothing to migrate
}
return err
}
migrated := 0
for _, entry := range entries {
// Only migrate single-character directories (cache hash directories like a, b, c, etc.)
if !entry.IsDir() || len(entry.Name()) != 1 {
continue
}
// Skip if it's already the diskcache directory
if entry.Name() == "d" && filepath.Join(oldDir, entry.Name()) == newDir {
continue
}
oldPath := filepath.Join(oldDir, entry.Name())
newPath := filepath.Join(newDir, entry.Name())
// Create parent directory in new location
if err := os.MkdirAll(newDir, fileutils.PermDir); err != nil {
return err
}
// Move the directory
if err := os.Rename(oldPath, newPath); err != nil {
// If rename fails (e.g., cross-device), skip this one
continue
}
migrated++
}
return nil
}
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
@@ -40,11 +95,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
defer mu.Unlock()
fileName := f.getFileName(key)
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
return err
}
if err := os.WriteFile(fileName, value, 0600); err != nil {
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
return err
}
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/stretchr/testify/require"
)
@@ -19,6 +20,9 @@ func TestFileCache(t *testing.T) {
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
)
// Set up file permissions before creating cache
fileutils.SetFsPermissions(0644, 0755)
// Create temporary directory for the cache
cacheDir, err := os.MkdirTemp("", cacheRoot)
require.NoError(t, err)
@@ -28,19 +32,20 @@ func TestFileCache(t *testing.T) {
require.NoError(t, err)
// store new key
// Note: NewFileCache creates a "diskcache" subdirectory, so the actual path includes it
err = cache.Store(ctx, key, []byte(value))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
// update existing key
err = cache.Store(ctx, key, []byte(newValue))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
// delete key
err = cache.Delete(ctx, key)
require.NoError(t, err)
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
require.False(t, exists)
}
+328 -4
View File
@@ -1,14 +1,18 @@
package files
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
@@ -86,6 +90,111 @@ func Test_GetRealPath(t *testing.T) {
}
}
// Regression: PUT / save must not os.Chmod to PermFile for existing files, or +x (and other mode bits) are lost (#2309).
func TestWriteFilePreservesExecutableBit(t *testing.T) {
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
fileutils.SetFsPermissions(0o644, 0o755)
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_writefile_exec", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
root := t.TempDir()
scriptName := "backupscript.sh"
scriptAbs := filepath.Join(root, scriptName)
if err := os.WriteFile(scriptAbs, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
t.Fatal(err)
}
sourceName := "test_writefile_exec"
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: root,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatal("Failed to get test index")
}
stopPoll := startScannerStatusPoll(t, idx, 1)
defer stopPoll()
waitForScannerReady(t, idx)
indexPath := "/" + scriptName
if err := WriteFile(sourceName, indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
t.Fatalf("WriteFile: %v", err)
}
st, err := os.Stat(scriptAbs)
if err != nil {
t.Fatal(err)
}
if st.Mode()&0o111 == 0 {
t.Fatalf("executable bits cleared after save: mode=%v", st.Mode())
}
indexing.StopAllScanners()
time.Sleep(50 * time.Millisecond)
indexing.ClearTestIndices()
}
func TestWriteFileRejectsExistingDirectory(t *testing.T) {
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
fileutils.SetFsPermissions(0o644, 0o755)
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_writefile_isdir", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
root := t.TempDir()
dirName := "folder"
if err := os.Mkdir(filepath.Join(root, dirName), 0o755); err != nil {
t.Fatal(err)
}
sourceName := "test_writefile_isdir"
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: root,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatal("Failed to get test index")
}
stopPoll := startScannerStatusPoll(t, idx, 1)
defer stopPoll()
waitForScannerReady(t, idx)
err := WriteFile(sourceName, "/"+dirName, strings.NewReader("file content"))
if err == nil {
t.Fatal("expected error when path is an existing directory")
}
if !errors.Is(err, liberrors.ErrIsDirectory) {
t.Fatalf("expected ErrIsDirectory, got: %v", err)
}
indexing.StopAllScanners()
time.Sleep(50 * time.Millisecond)
indexing.ClearTestIndices()
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
@@ -208,7 +317,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
// Initialize the database first (use test helper to avoid permission issues)
if indexing.GetIndexDB() == nil {
db, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
@@ -219,7 +328,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
}, true, false) // true for mock mode, false for isNewDb
// Get the index and set up mock data
idx := indexing.GetIndex("test")
@@ -318,7 +427,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
// Initialize the database first (use test helper to avoid permission issues)
if indexing.GetIndexDB() == nil {
db, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
@@ -329,7 +438,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
}, true, false) // true for mock mode, false for isNewDb
// Get the index and set up mock data
idx := indexing.GetIndex("test")
@@ -409,3 +518,218 @@ func TestOverrideFileToDirectory(t *testing.T) {
t.Error("Directory 'Test Object' should have been added to Folders")
}
}
// TestDeleteFilesRootProtection tests that DeleteFiles refuses to delete
// the source root directory itself, preventing catastrophic data loss.
// This is a regression test for the safety guard added to prevent accidental
// deletion of the entire source directory.
func TestDeleteFilesRootProtection(t *testing.T) {
// Use a temporary directory for cache
tmpDir := t.TempDir()
originalCacheDir := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = tmpDir
defer func() {
settings.Config.Server.CacheDir = originalCacheDir
}()
// Ensure fileutils permissions are set
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0644, 0755)
}
// Initialize the database
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_root_protection", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
// Create a real temporary directory to use as source root
realTmpDir := t.TempDir()
// Initialize the index with a real path
indexing.Initialize(&settings.Source{
Name: "test_root_protection",
Path: realTmpDir,
}, false, false) // false for mock mode (real path), false for isNewDb
// Wait for scanner to be ready
idx := indexing.GetIndex("test_root_protection")
if idx == nil {
t.Fatal("Failed to get test index")
}
// Wait for scanner to finish
for i := 0; i < 50; i++ {
time.Sleep(10 * time.Millisecond)
status := idx.GetScannerStatus()
if status["status"] == "ready" || status["status"] == "unavailable" {
break
}
}
// Test: Attempting to delete the root directory should return an error
err := DeleteFiles("test_root_protection", realTmpDir, true)
if err == nil {
t.Error("DeleteFiles should return an error when trying to delete root directory")
}
// Verify the error message contains expected text
expectedErrMsg := "refusing to delete source root directory"
if err != nil && !strings.Contains(err.Error(), expectedErrMsg) {
t.Errorf("Expected error message to contain '%s', got: %v", expectedErrMsg, err.Error())
}
// Test with trailing slash - should still be blocked
err = DeleteFiles("test_root_protection", realTmpDir+"/", true)
if err == nil {
t.Error("DeleteFiles should return an error for root directory with trailing slash")
}
// Test with cleaned path variations
err = DeleteFiles("test_root_protection", realTmpDir+"/.", true)
if err == nil {
t.Error("DeleteFiles should return an error for root directory with /. suffix")
}
}
// TestDeleteFilesSubfolderWithRootName tests that deleting a subfolder
// with the same name as the root directory works correctly and doesn't
// accidentally delete the root.
// Regression test for path handling bug where /srv/srv could be mistaken for /srv.
func TestDeleteFilesSubfolderWithRootName(t *testing.T) {
for iter := 1; iter <= raceStressIterations; iter++ {
iter := iter
t.Run(fmt.Sprintf("stress-iter-%d", iter), func(t *testing.T) {
runDeleteFilesSubfolderWithRaceStress(t, iter)
})
}
}
const (
raceStressIterations = 3
raceStatusPollInterval = 5 * time.Millisecond
waitStatusMaxTries = 100
waitStatusSleep = 50 * time.Millisecond
)
func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
t.Helper()
start := time.Now()
t.Logf("iteration %d: start", iter)
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0644, 0755)
}
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_subfolder_rootname", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
realTmpDir := t.TempDir()
subfolderName := filepath.Base(realTmpDir)
subfolderPath := filepath.Join(realTmpDir, subfolderName)
if err := os.Mkdir(subfolderPath, 0755); err != nil {
t.Fatalf("Failed to create subfolder: %v", err)
}
testFile := filepath.Join(subfolderPath, "test.txt")
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
sourceName := fmt.Sprintf("test_subfolder_rootname-%d", iter)
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: realTmpDir,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatalf("Failed to get test index for %s", sourceName)
}
stopPolling := startScannerStatusPoll(t, idx, iter)
defer stopPolling()
waitForScannerReady(t, idx)
err := DeleteFiles(sourceName, subfolderPath, true)
stopPolling()
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
if err != nil {
t.Fatalf("DeleteFiles should succeed for subfolder with root-like name, got error: %v", err)
}
if _, err := os.Stat(subfolderPath); !os.IsNotExist(err) {
t.Fatal("Subfolder should have been deleted")
}
if _, err := os.Stat(realTmpDir); os.IsNotExist(err) {
t.Fatal("Root directory should NOT have been deleted")
}
indexing.StopAllScanners()
// Give scanners time to finish their cleanup (defer blocks in tryAcquireAndScan)
time.Sleep(100 * time.Millisecond)
indexing.ClearTestIndices()
t.Logf("iteration %d: cleaned scanners and indices", iter)
}
func waitForScannerReady(t *testing.T, idx *indexing.Index) {
t.Helper()
// Give the scanners a moment to initialize, but don't wait for full "ready" status
// The initial scan can take longer than the test timeout, but DeleteFiles will work
// as long as the scanners are initialized and running
time.Sleep(500 * time.Millisecond)
// Verify scanners exist
status := idx.GetScannerStatus()
if totalScanners, ok := status["totalScanners"].(int); !ok || totalScanners == 0 {
t.Fatal("No scanners were created")
}
t.Log("Scanners initialized, proceeding with delete operation")
}
func startScannerStatusPoll(t *testing.T, idx *indexing.Index, iter int) func() {
t.Helper()
stop := make(chan struct{})
done := make(chan struct{})
var once sync.Once
go func() {
ticker := time.NewTicker(raceStatusPollInterval)
defer ticker.Stop()
for {
select {
case <-stop:
close(done)
return
case <-ticker.C:
idx.GetScannerStatus()
}
}
}()
return func() {
once.Do(func() {
close(stop)
<-done
t.Logf("iteration %d: poll watcher stopped", iter)
})
}
}
+201 -253
View File
@@ -2,7 +2,6 @@ package files
import (
"context"
"encoding/base64"
"fmt"
"io"
"os"
@@ -11,8 +10,6 @@ import (
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/dhowden/tag"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -28,6 +25,39 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
var CheckPermissionsFunc = checkPermissionsImpl
// addMetadataToChildren runs concurrent ffmpeg/tag extraction for audio/video children.
// Each goroutine is registered on wg. Caller must invoke wg.Wait() after scheduling.
func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileOptions, wg *sync.WaitGroup) {
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService == nil {
return
}
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if !isItemAudio && !isItemVideo {
continue
}
isAudio := isItemAudio
wg.Go(func() {
fullPath := response.RealPath + "/" + fileItem.Name
if isAudio {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
}
}
})
}
}
// processDirectoryMetadata extracts metadata for audio/video files in directories
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
metadataCount := 0
@@ -39,59 +69,20 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
metadataCount++
}
}
var wg sync.WaitGroup
// Set hasMetadata flag if there are files with potential metadata
if metadataCount > 0 {
response.HasMetadata = true
}
// Only process metadata if explicitly requested
if opts.Metadata && metadataCount > 0 {
processedCount := 0
// Create a single shared FFmpegService instance for all files to coordinate concurrency
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService != nil {
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if isItemAudio || isItemVideo {
wg.Go(func() {
// Extract metadata for audio files (without album art for performance)
if isItemAudio {
err := extractAudioMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, opts.AlbumArt, opts.Metadata, sharedFFmpegService)
if err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
} else {
mu.Lock()
processedCount++
mu.Unlock()
}
} else {
// Extract duration for video files
err := extractVideoMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, sharedFFmpegService)
if err != nil {
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
} else {
mu.Lock()
processedCount++
mu.Unlock()
}
}
})
}
}
// Wait for all goroutines to complete
wg.Wait()
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true)
err := idx.RefreshDirectory(indexPath, false)
if err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
if opts.Metadata && metadataCount > 0 {
addMetadataToChildren(response, opts, &wg)
}
wg.Wait()
}
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
@@ -110,35 +101,41 @@ func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileIn
}
}
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
// CheckPermissions validates user access and returns the resolved index path and user scope
func CheckPermissions(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
return CheckPermissionsFunc(opts, access, user)
}
// checkPermissionsImpl is the actual implementation of CheckPermissions
func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
if access == nil {
return response, fmt.Errorf("access not provided")
return "", "", fmt.Errorf("access not provided")
}
if user == nil {
return response, fmt.Errorf("user not provided")
return "", "", fmt.Errorf("user not provided")
}
if opts.Path == "" {
return response, fmt.Errorf("path not provided")
return "", "", fmt.Errorf("path not provided")
}
if opts.Source == "" {
return response, fmt.Errorf("source not provided")
return "", "", fmt.Errorf("source not provided")
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
return "", "", fmt.Errorf("could not get index: %v ", opts.Source)
}
// Resolve user scope
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
if scopeErr != nil || userScope == "" {
return response, fmt.Errorf("user has no access to source: %v", opts.Source)
return "", "", fmt.Errorf("user has no access to source: %v", opts.Source)
}
safePath, err := utils.SanitizeUserPath(opts.Path)
if err != nil {
return response, errors.ErrAccessDenied
return "", "", errors.ErrAccessDenied
}
// Combine scope + sanitized path
@@ -146,49 +143,124 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.
// Layer 1: USER ACCESS CONTROL
// Quick check: Does THIS user have permission?
if !access.Permitted(idx.Path, indexPath, user.Username) {
return response, errors.ErrAccessDenied
return "", "", errors.ErrAccessDenied
}
return indexPath, userScope, nil
}
type Items struct {
Files []string `json:"files,omitempty"`
Folders []string `json:"folders,omitempty"`
}
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
items := Items{}
indexPath, _, err := CheckPermissions(opts, access, user)
if err != nil {
return items, err
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return items, fmt.Errorf("could not get index: %v ", opts.Source)
}
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
Expand: true,
SkipExtendedAttrs: true,
})
if err != nil {
return items, err // Path excluded by index rules OR doesn't exist
}
if info.Type != "directory" {
return items, fmt.Errorf("path is not a directory: %v ", indexPath)
}
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
return items, err
}
if opts.Only == "files" || opts.Only == "" {
for _, file := range info.Files {
items.Files = append(items.Files, file.Name)
}
}
if opts.Only == "folders" || opts.Only == "" {
for _, folder := range info.Folders {
items.Folders = append(items.Folders, folder.Name)
}
}
return items, nil
}
// FileInfoFasterFunc is a variable that can be mocked in tests
var FileInfoFasterFunc = fileInfoFasterImpl
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
return FileInfoFasterFunc(opts, access, user, share)
}
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
indexPath, userScope, err := CheckPermissions(opts, access, user)
if err != nil {
return response, err
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
// Layer 2: INDEX RULES (global)
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
Expand: opts.Expand,
IsRoutineScan: false, // API call
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
Expand: opts.Expand,
SkipExtendedAttrs: opts.SkipExtendedAttrs,
})
if err != nil {
return response, err // Path excluded by index rules OR doesn't exist
}
// otherwise response keeps unfiltered Folders/Files while CheckChildItemAccess only mutates info.
if info.Type == "directory" {
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
return response, err
}
}
// Build response
response.FileInfo = *info
response.RealPath = filepath.Join(idx.Path, indexPath)
response.Source = opts.Source
// Layer 3: FILTER CHILDREN (user access)
// Remove child items THIS user can't access
if info.Type == "directory" {
if err := access.CheckChildItemAccess(response, idx, user.Username); err != nil {
return response, err
if share != nil && user.Permissions.Share && opts.ShowSharedAttr {
for i := range response.Files {
file := &response.Files[i]
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path, user.ID)
}
for i := range response.Folders {
folder := &response.Folders[i]
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path, user.ID)
}
response.IsShared = share.IsShared(response.Path, idx.Path, user.ID)
}
defer finalizeResponse(response, info, response.RealPath, user, userScope)
if opts.SkipExtendedAttrs {
return response, nil
}
// Process directory metadata if requested
if info.Type == "directory" {
processDirectoryMetadata(response, idx, opts)
}
// Process single file content/metadata
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
if opts.Content || opts.Metadata || isAudioVideo {
processContent(response, idx, opts)
}
// Finalize response (OnlyOffice ID, scope stripping)
finalizeResponse(response, info, response.RealPath, user, userScope)
return response, nil
}
@@ -199,9 +271,13 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
if isFolder {
return
}
if isVideo && !opts.Metadata {
return
}
if isAudio && !(opts.Metadata || opts.AlbumArt) {
return
}
if isVideo {
// Extract duration for video
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
@@ -212,23 +288,21 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
info.Metadata = extItem.Metadata
}
// Handle subtitles if requested
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
if exists {
info.GetSubtitles(parentInfo)
}
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())
}
}
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
info.Subtitles = append(info.Subtitles, subtitles...)
}
return
}
// Audio: run tag/album-art extraction when full metadata or preview (AlbumArt) is requested.
// Preview uses AlbumArt without Metadata so we do not load arbitrary files as text content.
if isAudio {
// Create an ExtendedItemInfo to hold the metadata
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
@@ -236,25 +310,14 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
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 != ""
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
}
return
}
// Process text content for non-video, non-audio files
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
// Check if file is text before reading content
isText, err := utils.IsTextFile(info.RealPath)
if err != nil {
logger.Debugf("could not check file type for: "+info.RealPath, info.Name, err)
return
}
if !isText {
// Not a text file, skip content extraction
return
}
content, err := getContent(info.RealPath)
if err != nil {
logger.Debugf("could not get content for file: "+info.RealPath, info.Name, err)
@@ -335,11 +398,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
// Extract album art and encode as base64 with strict size limits
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
if picture := m.Picture(); picture != nil && picture.Data != nil {
// More aggressive size limit to prevent memory issues (max 5MB)
// Size limit to prevent memory issues (max 5MB)
if len(picture.Data) <= 5*1024*1024 {
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
item.Metadata.AlbumArt = picture.Data
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
}
@@ -376,6 +439,11 @@ func DeleteFiles(source, absPath string, isDir bool) error {
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
// Safety guard: never allow deletion of the source root directory itself
cleanAbs := filepath.Clean(absPath)
if cleanAbs == filepath.Clean(index.Path) {
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
}
if !index.Config.DisableIndexing {
indexPath := index.MakeIndexPath(absPath, isDir)
@@ -693,22 +761,21 @@ func WriteFile(source, path string, in io.Reader) error {
if err != nil {
return err
}
applyDefaultFilePerm := false
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)
}
stat, err = os.Stat(realPath)
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("stat file: %w", err)
}
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
applyDefaultFilePerm = true
} else if stat.IsDir() {
return fmt.Errorf("%w: %q", errors.ErrIsDirectory, path)
}
// 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)
// For new files: permissions are set to fileutils.PermFile (subject to umask, then Chmod bypasses umask)
// For existing regular files: do not Chmod so saves preserve mode and special bits
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
@@ -721,11 +788,11 @@ func WriteFile(source, path string, in io.Reader) error {
return err
}
// Explicitly set file permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
// Handle chmod error gracefully
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
if applyDefaultFilePerm {
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
}
}
// Refresh the file itself
@@ -746,149 +813,30 @@ func WriteFile(source, path string, in io.Reader) error {
}
// getContent reads and returns the file content if it's considered an editable text file.
// This is a wrapper around utils.IsTextFile that also returns the file content.
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)
// First check if it's a text file using the consolidated validation logic
isText, err := utils.IsTextFile(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
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
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)
// It's a text file, read and return the content
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
// Handle empty file (original logic - returns specific string)
// Handle empty file (return specific marker 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
return string(content), nil
}
func IsNamedPipe(mode os.FileMode) bool {
+108 -13
View File
@@ -108,7 +108,6 @@ var types = map[string]string{
".chrt": "application/vnd.kde.kchart",
".class": "application/java",
".com": ContentTextHeaderValue,
".conf": ContentTextHeaderValue,
".cpio": "application/x-cpio",
".cpp": "text/x-c",
".cpt": "application/mac-compactpro",
@@ -149,7 +148,11 @@ var types = map[string]string{
".es": "application/x-esrehber",
".etx": "text/x-setext",
".evy": "application/envoy",
".exe": "application/octet-stream",
".exe": "application/x-msdownload",
".dll": "application/x-msdownload",
".bat": "application/x-msdos-program",
".cmd": "application/x-msdos-program",
".msi": "application/x-msdownload",
".f77": "text/x-fortran",
".f90": "text/x-fortran",
".f": "text/x-fortran",
@@ -225,7 +228,6 @@ var types = map[string]string{
".js": ContentJavascriptHeaderValue,
".mjs": ContentJavascriptHeaderValue,
".json": ContentJSONHeaderValue,
".vue": ContentJavascriptHeaderValue,
".jut": "image/jutvision",
".kar": "audio/midi",
".karbon": "application/vnd.kde.karbon",
@@ -243,8 +245,8 @@ var types = map[string]string{
".la": "audio/nspaudio",
".lam": "audio/x-liveaudio",
".latex": "application/x-latex",
".lha": "application/lha",
".lhx": "application/octet-stream",
".lha": "application/x-lzh-compressed",
".lhx": "application/x-lzh-compressed",
".list": ContentTextHeaderValue,
".lma": "audio/nspaudio",
".log": ContentTextHeaderValue,
@@ -257,7 +259,6 @@ var types = map[string]string{
".m1v": "video/mpeg",
".m2a": "audio/mpeg",
".m2v": "video/mpeg",
".m3u": "audio/x-mpegurl",
".m": "text/x-m",
".man": "application/x-troff-man",
".manifest": "text/cache-manifest",
@@ -307,7 +308,6 @@ var types = map[string]string{
".ncm": "application/vndnokiaconfiguration-message",
".nif": "image/x-niff",
".niff": "image/x-niff",
".nix": "application/x-mix-transfer",
".nsc": "application/x-conference",
".nvd": "application/x-navidoc",
".o": "application/octet-stream",
@@ -343,15 +343,14 @@ var types = map[string]string{
".p7m": "application/pkcs7-mime",
".p7r": "application/x-pkcs7-certreqresp",
".p7s": "application/pkcs7-signature",
".p": "text/x-pascal",
".part": "application/pro_eng",
".pas": "text/pascal",
".pbm": "image/x-portable-bitmap",
".pcl": "application/vndhp-pcl",
".pct": "image/x-pict",
".pcx": "image/x-pcx",
".pdb": "chemical/x-pdb",
".pdf": "application/pdf",
".pdfa": "application/pdf",
".pfunk": "audio/make",
".pgm": "image/x-portable-graymap",
".pic": "image/pict",
@@ -375,7 +374,7 @@ var types = map[string]string{
".pre": "application/x-freelance",
".prt": "application/pro_eng",
".ps": "application/postscript",
".psd": "application/octet-stream",
".psd": "image/vnd.adobe.photoshop",
".pvu": "paleovu/x-pv",
".pwz": "application/vndms-powerpoint",
".py": "text/x-scriptphyton",
@@ -443,9 +442,11 @@ var types = map[string]string{
".sprite": "application/x-sprite",
".spx": "audio/ogg",
".src": "application/x-wais-source",
".srt": "text/plain",
".srt": "text/subtitle-srt",
".ass": "text/subtitle-ass",
".vtt": "text/subtitle-vtt",
".ssa": "text/subtitle-ssa",
".sbv": "text/plain",
".ssa": "text/plain",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
".sst": "application/vndms-pkicertstore",
@@ -597,11 +598,105 @@ var types = map[string]string{
".ogx": "application/ogg",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".webm": "video/webm",
".weba": "audio/webm",
".caf": "audio/x-caf",
".m3u8": "application/x-mpegURL",
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
".m3u8": "text/x-mpegURL",
".pls": "text/x-scpls",
".xspf": "application/xspf+xml",
".mpd": "application/dash+xml",
".webp": "image/webp",
".epub": "application/epub+zip",
".db": "application/x-db",
".db2": "application/x-db",
".db3": "application/x-db",
".sql": "application/sql",
".sqlite": "application/vnd.sqlite3",
".sqlite3": "application/vnd.sqlite3",
".yaml": "application/yaml",
".app": "application/x-application",
".geojson": "application/geo+json",
".sketch": "application/x-sketch",
".bak": "application/backup",
".backup": "application/backup",
".xfc": "application/x-xcf",
".vue": "text/vue",
".conf": "text/config-file",
".ini": "text/ini",
".ps1": "application/x-powershell",
".ps2": "application/x-powershell",
".ps3": "application/x-powershell",
".dart": "application/vnd.dart",
".flutter": "text/flutter",
".tsx": "text/x-typescript",
".ts": "text/x-typescript",
".php": "application/x-php",
".php2": "application/x-php",
".php3": "application/x-php",
".phtml": "application/x-php",
".p": "text/x-pascal",
".pas": "text/x-pascal",
".zig": "text/x-zig",
".ex": "text/x-elixir",
".exs": "text/x-elixir",
".json5": "application/json5",
".pmx": "model/x-pmx",
".nix": "text/nix-lang",
".cjs": "text/javascript",
".torrent": "application/x-bittorrent",
".tmp": "text/tmp",
".temp": "text/tmp",
// the ones below are some from: https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
".adp": "audio/adpcm",
".mp4a": "audio/mp4",
".mp2a": "audio/mpeg",
".m3a": "audio/mpeg",
".sil": "audio/silk",
".uva": "audio/vnd.dece.audio",
".uvva": "audio/vnd.dece.audio",
".eol": "audio/vnd.digital-winds",
".dra": "audio/vnd.dra",
".dts": "audio/vnd.dts",
".dtshd": "audio/vnd.dts.hd",
".lvp": "audio/vnd.lucent.voice",
".pya": "audio/vnd.ms-playready.media.pya",
".rip": "audio/vnd.rip",
".mka": "audio/x-matroska",
".h261": "video/h261",
".h263": "video/h263",
".h264": "video/h264",
".jpgv": "video/jpeg",
".jpm": "video/jpm",
".jpgm": "video/jpm",
".mj2": "video/mj2",
".mjp2": "video/mj2",
".m2t": "video/mp2t",
".m2ts": "video/mp2t",
".mts": "video/mp2t",
".mp4v": "video/mp4",
".mpg4": "video/mp4",
".uvh": "video/vnd.dece.hd",
".uvvh": "video/vnd.dece.hd",
".uvm": "video/vnd.dece.mobile",
".uvvm": "video/vnd.dece.mobile",
".uvp": "video/vnd.dece.pd",
".uvvp": "video/vnd.dece.pd",
".uvs": "video/vnd.dece.sd",
".uvvs": "video/vnd.dece.sd",
".uvv": "video/vnd.dece.video",
".uvvv": "video/vnd.dece.video",
".dvb": "video/vnd.dvb.file",
".fvt": "video/vnd.fvt",
".mxu": "video/vnd.mpegurl",
".pyv": "video/vnd.ms-playready.media.pyv",
".uvu": "video/vnd.uvvu.mp4",
".uvvu": "video/vnd.uvvu.mp4",
".f4v": "video/x-f4v",
".mk3d": "video/x-matroska",
".mks": "video/x-matroska",
".vob": "video/x-ms-vob",
".smv": "video/x-smv",
}
//nolint:gochecknoinits
+13 -30
View File
@@ -19,7 +19,7 @@ func MakeUserDir(fullPath string) error {
return nil
}
func MakeUserDirs(u *users.User, disableScopeChange bool) error {
func MakeUserDirs(u *users.User, createDir bool) error {
cleanedUserName := users.CleanUsername(u.Username)
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
@@ -27,48 +27,31 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
for i, scope := range u.Scopes {
source, ok := settings.Config.Server.SourceMap[scope.Name]
if !ok {
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
continue
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
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if createDir {
if source.Config.CreateUserDir && filepath.Base(scope.Scope) != cleanedUserName {
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
fullPath = filepath.Join(fullPath, cleanedUserName)
}
if !Exists(parentDir) {
if err := MakeUserDir(parentDir); err != nil {
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
continue
}
}
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
// create directory exactly as specified
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if !Exists(parentDir) {
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
continue
}
err := MakeUserDir(fullPath)
if err != nil {
logger.Errorf("create user: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
return fmt.Errorf("MakeUserDirs: 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) {
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
continue
if !Exists(fullPath) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", fullPath)
}
}
u.Scopes[i] = scope
}
return nil
+36
View File
@@ -0,0 +1,36 @@
package fileutils
import (
"io/fs"
"net/http"
)
var (
assetFs fs.FS
)
// Custom dirFS to handle both embedded and non-embedded file systems
type dirFS struct {
http.Dir
}
// Implement the Open method for dirFS, which wraps http.Dir
func (d dirFS) Open(name string) (fs.File, error) {
return d.Dir.Open(name)
}
// InitAssetFS initializes the asset filesystem for the application
// This should be called once during startup before http or preview services start
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
if useEmbedded {
assetFs = embeddedAssets
} else {
// Dev mode: Serve files from http/dist directory
assetFs = dirFS{Dir: http.Dir("http/dist")}
}
}
// GetAssetFS returns the initialized asset filesystem
func GetAssetFS() fs.FS {
return assetFs
}
+39 -3
View File
@@ -12,9 +12,45 @@ import (
var PermFile os.FileMode
var PermDir os.FileMode
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
PermFile = PermFileOctal
PermDir = PermDirOctal
// SetFsPermissions sets create modes from Unix chmod(2)-style values (for example
// values produced by strconv.ParseUint(s, 8, 32)). setuid, setgid, and sticky
// bits must be converted for Go's os.FileMode; a plain cast from Unix octal is
// incorrect for those bits.
func SetFsPermissions(unixFileMode, unixDirMode uint32) {
PermFile = unixModeToFileMode(unixFileMode)
PermDir = unixModeToFileMode(unixDirMode)
}
// EffectiveDirPerm returns [PermDir] if [SetFsPermissions] was called, otherwise 0o755. On Unix,
// [os.Mkdir] with perm 0 fails with permission denied, so use this (or [PermFile]) for creates when
// the process has not set globals yet.
func EffectiveDirPerm() os.FileMode {
if PermDir == 0 {
return 0o755
}
return PermDir
}
// EffectiveFilePerm returns [PermFile] if set, otherwise 0o644.
func EffectiveFilePerm() os.FileMode {
if PermFile == 0 {
return 0o644
}
return PermFile
}
func unixModeToFileMode(u uint32) os.FileMode {
m := os.FileMode(u & 0777)
if u&0o4000 != 0 {
m |= os.ModeSetuid
}
if u&0o2000 != 0 {
m |= os.ModeSetgid
}
if u&0o1000 != 0 {
m |= os.ModeSticky
}
return m
}
// MoveFile moves a file from src to dst.
+17 -1
View File
@@ -1,6 +1,22 @@
package fileutils
import "testing"
import (
"os"
"testing"
)
func TestUnixModeToFileMode_setgid(t *testing.T) {
m := unixModeToFileMode(0o2770)
if got := m.Perm(); got != 0o770 {
t.Fatalf("Perm() = %#o, want 0770", got)
}
if m&os.ModeSetgid == 0 {
t.Fatal("expected ModeSetgid")
}
if m&os.ModeSetuid != 0 || m&os.ModeSticky != 0 {
t.Fatalf("unexpected special bits: %#o", m)
}
}
func TestCommonPrefix(t *testing.T) {
testCases := map[string]struct {
@@ -29,3 +29,16 @@ func GetFreeSpace(path string) (uint64, error) {
free := uint64(stat.Bavail) * uint64(stat.Bsize)
return free, nil
}
// GetPartitionUsed returns the used space for Unix systems (total - free)
func GetPartitionUsed(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
total := uint64(stat.Blocks) * uint64(stat.Bsize)
free := uint64(stat.Bavail) * uint64(stat.Bsize)
used := total - free
return used, nil
}
@@ -33,3 +33,18 @@ func GetFreeSpace(path string) (uint64, error) {
}
return freeBytes, nil
}
// GetPartitionUsed returns the used space for Windows systems (total - free)
func GetPartitionUsed(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
used := totalBytes - freeBytes
return used, nil
}
+48 -17
View File
@@ -1,34 +1,65 @@
package auth
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"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
const FB_ISSUER = "FileBrowser Quantum" // don't change
// IsRevokedApiToken checks if a token is in the revoked list.
func IsRevokedApiToken(accessStore *access.Storage, token string) bool {
if accessStore == nil {
return false
}
return accessStore.IsTokenRevoked(token)
}
func RevokeAPIKey(key string) {
revokeMu.Lock()
defer revokeMu.Unlock()
revokedApiKeyList[key] = struct{}{}
// RevokeApiToken adds a token to the revoked list.
func RevokeApiToken(accessStore *access.Storage, token string) error {
if accessStore == nil {
return fmt.Errorf("access storage not available")
}
return accessStore.RevokeToken(token)
}
func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions, minimal bool) (string, users.AuthToken, error) {
if _, ok := user.Tokens[name]; ok {
return "", users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
}
now := time.Now()
expires := now.Add(duration)
// Create minimal token with only JWT standard claims
claim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: FB_ISSUER,
},
},
}
if !minimal {
claim.Permissions = perms
claim.BelongsTo = user.ID
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
tokenString, err := token.SignedString([]byte(settings.Config.Auth.Key))
if err != nil {
return "", users.AuthToken{}, err
}
return tokenString, claim, nil
}
+16 -9
View File
@@ -9,12 +9,14 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// JSONAuth is a json implementation of an Auther.
type JSONAuth struct {
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
DisableOtp bool `json:"disableOtp" yaml:"disableOtp"`
}
// Auth authenticates the user via a json in content body.
@@ -22,12 +24,12 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
password := r.Header.Get("X-Password")
totpCode := r.Header.Get("X-Secret")
// URL-decode password to support special characters in headers
password, err := url.QueryUnescape(password)
if err != nil {
return nil, fmt.Errorf("invalid password encoding: %v", err)
}
totpCode := r.Header.Get("X-Secret")
// If ReCaptcha is enabled, check the code.
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
@@ -42,17 +44,22 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
}
}
user, err := userStore.Get(username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
user, getErr := userStore.Get(username)
var passwordHash string
if getErr != nil {
passwordHash = utils.InvalidPasswordHash
} else {
passwordHash = user.Password
}
err = users.CheckPwd(password, user.Password)
// always run checkPwd to prevent timing attacks
err = utils.CheckPwd(password, passwordHash)
if err != nil {
return nil, err
}
// check for OTP for password
if user.TOTPSecret != "" {
if getErr != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
if user.TOTPSecret != "" && !auther.DisableOtp {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
}
+375
View File
@@ -0,0 +1,375 @@
// Package auth_test contains timing attack tests for authentication.
//
// These tests verify that the JSON authentication implementation is protected
// against timing attacks that could leak information about valid usernames.
//
// Timing Attack Background:
// A timing attack is a side-channel attack where an attacker measures how long
// authentication operations take to determine if a username exists in the system.
// If authentication fails faster for non-existent users than for valid users
// with wrong passwords, an attacker can enumerate valid usernames.
//
// Protection Mechanism:
// The json.go Auth() implementation protects against timing attacks by:
// 1. Always running the bcrypt password comparison, even for non-existent users
// 2. Using utils.InvalidPasswordHash (a pre-computed bcrypt hash) when the user doesn't exist
// 3. Ensuring both valid and invalid user authentication paths take similar time
//
// Test Coverage:
// - TestJSONAuth_NoTimingAttack: Statistical analysis of timing differences
// - TestJSONAuth_InvalidPasswordHashInitialized: Verifies protection is in place
// - TestJSONAuth_ValidUserVsInvalidUserDirectComparison: Direct comparison test
package auth_test
import (
"fmt"
"math"
"net/http"
"net/url"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// mockUserBackend is a simple in-memory user storage for testing.
type mockUserBackend struct {
usersByUsername map[string]*users.User
usersByID map[uint]*users.User
}
func newMockUserBackend() *mockUserBackend {
return &mockUserBackend{
usersByUsername: make(map[string]*users.User),
usersByID: make(map[uint]*users.User),
}
}
func (m *mockUserBackend) GetBy(id interface{}) (*users.User, error) {
switch v := id.(type) {
case string:
if user, ok := m.usersByUsername[v]; ok {
return user, nil
}
return nil, fmt.Errorf("user not found: %s", v)
case uint:
if user, ok := m.usersByID[v]; ok {
return user, nil
}
return nil, fmt.Errorf("user not found: %d", v)
default:
return nil, fmt.Errorf("invalid id type")
}
}
func (m *mockUserBackend) Gets() ([]*users.User, error) {
result := make([]*users.User, 0, len(m.usersByID))
for _, user := range m.usersByID {
result = append(result, user)
}
return result, nil
}
func (m *mockUserBackend) Save(u *users.User, changePass bool, disableScopeChange bool) error {
m.usersByUsername[u.Username] = u
m.usersByID[u.ID] = u
return nil
}
func (m *mockUserBackend) Update(u *users.User, adminActor bool, fields ...string) error {
m.usersByUsername[u.Username] = u
m.usersByID[u.ID] = u
return nil
}
func (m *mockUserBackend) DeleteByID(id uint) error {
if user, ok := m.usersByID[id]; ok {
delete(m.usersByUsername, user.Username)
delete(m.usersByID, id)
return nil
}
return fmt.Errorf("user not found: %d", id)
}
func (m *mockUserBackend) DeleteByUsername(username string) error {
if user, ok := m.usersByUsername[username]; ok {
delete(m.usersByID, user.ID)
delete(m.usersByUsername, username)
return nil
}
return fmt.Errorf("user not found: %s", username)
}
// setupTestUsers creates a user storage with 2 valid test users
func setupTestUsers(t *testing.T) *users.Storage {
t.Helper()
// Reduce bcrypt cost for faster tests (default is 10, min is 4)
// This significantly speeds up password hashing during tests
originalCost := utils.BcryptCost
utils.BcryptCost = 4 // Minimum bcrypt cost for testing
t.Cleanup(func() {
utils.BcryptCost = originalCost // Restore original cost after test
})
// Initialize InvalidPasswordHash to prevent timing attacks
// This simulates the application startup initialization
err := utils.SetInvalidPasswordHash()
if err != nil {
t.Fatalf("Failed to set invalid password hash: %v", err)
}
backend := newMockUserBackend()
storage := users.NewStorage(backend)
// Create two valid users with hashed passwords
password1Hash, err := utils.HashPwd("password123")
if err != nil {
t.Fatalf("Failed to hash password: %v", err)
}
password2Hash, err := utils.HashPwd("securepass456")
if err != nil {
t.Fatalf("Failed to hash password: %v", err)
}
user1 := &users.User{
ID: 1,
Username: "admin",
NonAdminEditable: users.NonAdminEditable{
Password: password1Hash,
},
LoginMethod: users.LoginMethodPassword,
}
user2 := &users.User{
ID: 2,
Username: "testuser",
NonAdminEditable: users.NonAdminEditable{
Password: password2Hash,
},
LoginMethod: users.LoginMethodPassword,
}
err = storage.Save(user1, false, false)
if err != nil {
t.Fatalf("Failed to save user1: %v", err)
}
err = storage.Save(user2, false, false)
if err != nil {
t.Fatalf("Failed to save user2: %v", err)
}
return storage
}
// measureAuthTime measures the time taken to authenticate with given credentials
func measureAuthTime(t *testing.T, auther auth.JSONAuth, userStore *users.Storage, username, password string) time.Duration {
t.Helper()
// Create a mock HTTP request
req, err := http.NewRequest("POST", "/api/auth/login?username="+url.QueryEscape(username), nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("X-Password", url.QueryEscape(password))
start := time.Now()
_, _ = auther.Auth(req, userStore)
elapsed := time.Since(start)
return elapsed
}
// TestJSONAuth_NoTimingAttack tests that authentication timing does not leak user existence
func TestJSONAuth_NoTimingAttack(t *testing.T) {
storage := setupTestUsers(t)
auther := auth.JSONAuth{}
// Test cases: mix of valid and invalid usernames
testCases := []struct {
username string
isValid bool
}{
{"admin", true},
{"testuser", true},
{"nonexistent1", false},
{"invaliduser", false},
{"notauser", false},
{"fakeadmin", false},
}
// Number of samples per test case for statistical analysis
samplesPerCase := 5
// Collect timing measurements
measurements := make(map[string][]time.Duration)
for _, tc := range testCases {
measurements[tc.username] = make([]time.Duration, 0, samplesPerCase)
}
// Run multiple iterations to gather statistical data
for i := 0; i < samplesPerCase; i++ {
// Randomize order to avoid cache effects
for _, tc := range testCases {
elapsed := measureAuthTime(t, auther, storage, tc.username, "wrongpassword123")
measurements[tc.username] = append(measurements[tc.username], elapsed)
}
}
// Calculate statistics for each test case
type stats struct {
mean time.Duration
stddev time.Duration
min time.Duration
max time.Duration
}
calculateStats := func(durations []time.Duration) stats {
if len(durations) == 0 {
return stats{}
}
// Calculate mean
var sum time.Duration
for _, d := range durations {
sum += d
}
mean := sum / time.Duration(len(durations))
// Calculate standard deviation
var variance float64
for _, d := range durations {
diff := float64(d - mean)
variance += diff * diff
}
variance /= float64(len(durations))
stddev := time.Duration(math.Sqrt(variance))
// Find min and max
min := durations[0]
max := durations[0]
for _, d := range durations {
if d < min {
min = d
}
if d > max {
max = d
}
}
return stats{mean: mean, stddev: stddev, min: min, max: max}
}
// Analyze results
validUserStats := make([]stats, 0)
invalidUserStats := make([]stats, 0)
t.Log("\n=== Timing Analysis Results ===")
for _, tc := range testCases {
s := calculateStats(measurements[tc.username])
t.Logf("%s (valid=%v): mean=%.4fms, stddev=%.4fms, min=%.4fms, max=%.4fms",
tc.username, tc.isValid,
float64(s.mean.Microseconds())/1000.0,
float64(s.stddev.Microseconds())/1000.0,
float64(s.min.Microseconds())/1000.0,
float64(s.max.Microseconds())/1000.0)
if tc.isValid {
validUserStats = append(validUserStats, s)
} else {
invalidUserStats = append(invalidUserStats, s)
}
}
// Calculate overall statistics for valid vs invalid users
var validMeanSum, invalidMeanSum time.Duration
for _, s := range validUserStats {
validMeanSum += s.mean
}
for _, s := range invalidUserStats {
invalidMeanSum += s.mean
}
validAvgMean := validMeanSum / time.Duration(len(validUserStats))
invalidAvgMean := invalidMeanSum / time.Duration(len(invalidUserStats))
t.Logf("\n=== Summary ===")
t.Logf("Valid users average: %.4fms", float64(validAvgMean.Microseconds())/1000.0)
t.Logf("Invalid users average: %.4fms", float64(invalidAvgMean.Microseconds())/1000.0)
// Calculate the percentage difference
diff := float64(validAvgMean - invalidAvgMean)
percentDiff := math.Abs(diff) / float64(validAvgMean) * 100.0
t.Logf("Difference: %.4fms (%.2f%%)", diff/1e6, percentDiff)
// Assert: The timing difference should be less than 20%
// Bcrypt's constant-time comparison should make both cases take similar time
// We allow 20% threshold to account for system noise and variability
threshold := 20.0 // percent
if percentDiff > threshold {
t.Errorf("TIMING ATTACK VULNERABILITY DETECTED: Valid vs invalid user timing differs by %.2f%% (threshold: %.2f%%)",
percentDiff, threshold)
t.Errorf("This difference could allow an attacker to enumerate valid usernames!")
} else {
t.Logf("✓ No timing attack vulnerability detected (difference: %.2f%% < threshold: %.2f%%)",
percentDiff, threshold)
}
// Additional check: Ensure no individual case is an obvious outlier
// Check if any single user's timing is significantly faster than bcrypt operations
allStats := append(validUserStats, invalidUserStats...)
var allMeansSum time.Duration
for _, s := range allStats {
allMeansSum += s.mean
}
overallMean := allMeansSum / time.Duration(len(allStats))
for _, tc := range testCases {
s := calculateStats(measurements[tc.username])
deviation := math.Abs(float64(s.mean-overallMean)) / float64(overallMean) * 100.0
// If any user is more than 30% faster/slower than average, it's suspicious
if deviation > 30.0 {
t.Errorf("OUTLIER DETECTED: %s deviates by %.2f%% from overall mean (%.4fms vs %.4fms)",
tc.username, deviation,
float64(s.mean.Microseconds())/1000.0,
float64(overallMean.Microseconds())/1000.0)
}
}
}
// TestJSONAuth_InvalidPasswordHashInitialized verifies that InvalidPasswordHash is properly set
func TestJSONAuth_InvalidPasswordHashInitialized(t *testing.T) {
// This test ensures that the InvalidPasswordHash is initialized
// If it's empty, timing attacks would be possible because invalid users
// would skip bcrypt comparison entirely
// Use lower bcrypt cost for faster testing
originalCost := utils.BcryptCost
utils.BcryptCost = 4
t.Cleanup(func() {
utils.BcryptCost = originalCost
})
err := utils.SetInvalidPasswordHash()
if err != nil {
t.Fatalf("Failed to set invalid password hash: %v", err)
}
if utils.InvalidPasswordHash == "" {
t.Fatal("InvalidPasswordHash is not initialized - timing attack vulnerability exists!")
}
// Verify that the invalid hash can be used for password comparison
// This should fail (wrong password) but take the same time as a real bcrypt comparison
err = utils.CheckPwd("anypassword", utils.InvalidPasswordHash)
if err == nil {
t.Fatal("CheckPwd should fail with InvalidPasswordHash")
}
t.Log("✓ InvalidPasswordHash is properly initialized")
}
+229
View File
@@ -0,0 +1,229 @@
package auth
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// MethodJwtAuth is used to identify JWT external authentication.
const MethodJwtAuth = "jwt"
// JwtAuth is an external JWT implementation of an auther.
// It verifies JWT tokens signed with a shared secret and extracts user identity.
type JwtAuth struct {
Secret string `json:"secret"` // shared secret for verifying JWT signatures
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256, etc.)
}
// ExternalJwtClaims represents the expected claims in an external JWT token
type ExternalJwtClaims struct {
jwt.RegisteredClaims
Username string `json:"sub"` // subject claim (username)
Email string `json:"email,omitempty"` // email claim
Name string `json:"name,omitempty"` // full name claim
Groups []string `json:"groups,omitempty"` // groups claim
Custom map[string]interface{} `json:"-"` // catch-all for custom claims
}
// Auth authenticates the user via an external JWT token.
// The token is verified using the configured secret and algorithm.
func (a JwtAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
// This should not be called directly - JWT auth is handled in middleware
// because we need access to the full config (header name, user identifier field, etc.)
return nil, fmt.Errorf("JWT auth must be handled in middleware")
}
// VerifyExternalJWT verifies an external JWT token and extracts the username claim
func VerifyExternalJWT(tokenString string, secret string, algorithm string, userIdentifierField string) (string, map[string]interface{}, error) {
// Parse the token with the secret
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify the signing method matches what we expect
expectedMethod := jwt.GetSigningMethod(algorithm)
if expectedMethod == nil {
return nil, fmt.Errorf("unsupported signing algorithm: %s", algorithm)
}
if token.Method.Alg() != expectedMethod.Alg() {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtVerificationKey(secret, token.Method, algorithm)
})
if err != nil {
logger.Debugf("JWT token verification failed: %v", err)
return "", nil, fmt.Errorf("invalid JWT token: %w", err)
}
if !token.Valid {
return "", nil, fmt.Errorf("JWT token is invalid")
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", nil, fmt.Errorf("failed to parse JWT claims")
}
// Check if token is expired
if err := claims.Valid(); err != nil {
logger.Debugf("JWT claims validation failed: %v", err)
return "", nil, fmt.Errorf("JWT token is expired or invalid: %w", err)
}
// Extract username from the configured field (default: "sub")
var username string
if userVal, ok := claims[userIdentifierField]; ok {
if userStr, ok := userVal.(string); ok {
username = userStr
}
}
if username == "" {
return "", nil, fmt.Errorf("JWT token missing required claim: %s", userIdentifierField)
}
// Convert claims to map for additional processing
claimsMap := make(map[string]interface{})
for k, v := range claims {
claimsMap[k] = v
}
logger.Debugf("Successfully verified JWT token for user: %s", username)
return username, claimsMap, nil
}
func jwtVerificationKey(secret string, method jwt.SigningMethod, algorithm string) (interface{}, error) {
switch method.(type) {
case *jwt.SigningMethodHMAC:
return []byte(secret), nil
case *jwt.SigningMethodRSA:
key, err := parsePublicKeyFromPEM(secret)
if err != nil {
return nil, err
}
rsaKey, ok := key.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("JWT secret must contain an RSA public key in PEM format for algorithm %s", algorithm)
}
return rsaKey, nil
case *jwt.SigningMethodECDSA:
key, err := parsePublicKeyFromPEM(secret)
if err != nil {
return nil, err
}
ecKey, ok := key.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("JWT secret must contain an ECDSA public key in PEM format for algorithm %s", algorithm)
}
if err := ecdsaCurveMatchesAlgorithm(algorithm, ecKey); err != nil {
return nil, err
}
return ecKey, nil
default:
return nil, fmt.Errorf("unsupported JWT signing method: %s", method.Alg())
}
}
func ecdsaCurveMatchesAlgorithm(algorithm string, pub *ecdsa.PublicKey) error {
var want elliptic.Curve
switch algorithm {
case "ES256":
want = elliptic.P256()
case "ES384":
want = elliptic.P384()
case "ES512":
want = elliptic.P521()
default:
return nil
}
if pub.Curve != want {
return fmt.Errorf("ECDSA public key curve does not match algorithm %s", algorithm)
}
return nil
}
func parsePublicKeyFromPEM(pemData string) (interface{}, error) {
rest := []byte(pemData)
var lastErr error
for len(rest) > 0 {
block, rem := pem.Decode(rest)
if block == nil {
break
}
rest = rem
var key interface{}
var err error
switch block.Type {
case "CERTIFICATE":
var cert *x509.Certificate
cert, err = x509.ParseCertificate(block.Bytes)
if err == nil {
key = cert.PublicKey
}
case "PUBLIC KEY":
key, err = x509.ParsePKIXPublicKey(block.Bytes)
case "RSA PUBLIC KEY":
key, err = x509.ParsePKCS1PublicKey(block.Bytes)
case "EC PUBLIC KEY":
key, err = x509.ParsePKIXPublicKey(block.Bytes)
default:
continue
}
if err != nil {
lastErr = err
continue
}
if key != nil {
return key, nil
}
}
if lastErr != nil {
return nil, fmt.Errorf("failed to parse JWT public key from PEM: %w", lastErr)
}
return nil, fmt.Errorf("no public key found in JWT secret (expected PEM: CERTIFICATE, PUBLIC KEY, RSA PUBLIC KEY, or EC PUBLIC KEY)")
}
// ExtractGroupsFromClaims extracts groups from JWT claims based on the configured groups claim field
func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField string) []string {
var groups []string
if groupsVal, ok := claims[groupsClaimField]; ok {
switch v := groupsVal.(type) {
case []interface{}:
// Groups as array of interfaces
for _, g := range v {
if groupStr, ok := g.(string); ok {
groups = append(groups, groupStr)
}
}
case []string:
// Groups as array of strings
groups = v
case string:
// Single group as string
groups = []string{v}
default:
// Try to unmarshal as JSON array
if jsonBytes, err := json.Marshal(v); err == nil {
var groupsArray []string
if err := json.Unmarshal(jsonBytes, &groupsArray); err == nil {
groups = groupsArray
}
}
}
}
return groups
}
+363
View File
@@ -0,0 +1,363 @@
package auth_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/auth"
)
func TestVerifyExternalJWT_ValidToken(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
username := "testuser"
// Create a valid JWT token
claims := jwt.MapClaims{
"sub": username,
"email": "test@example.com",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token
extractedUsername, extractedClaims, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT() failed: %v", err)
}
if extractedUsername != username {
t.Errorf("Expected username %s, got %s", username, extractedUsername)
}
if extractedClaims["email"] != "test@example.com" {
t.Errorf("Expected email test@example.com, got %v", extractedClaims["email"])
}
}
func TestVerifyExternalJWT_ExpiredToken(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
// Create an expired JWT token
claims := jwt.MapClaims{
"sub": "testuser",
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
"iat": time.Now().Add(-2 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token - should fail
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
if err == nil {
t.Fatal("VerifyExternalJWT() should fail for expired token")
}
}
func TestVerifyExternalJWT_InvalidSignature(t *testing.T) {
secret := "test-secret-key"
wrongSecret := "wrong-secret-key"
algorithm := "HS256"
// Create a token with one secret
claims := jwt.MapClaims{
"sub": "testuser",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Try to verify with a different secret - should fail
_, _, err = auth.VerifyExternalJWT(tokenString, wrongSecret, algorithm, "sub")
if err == nil {
t.Fatal("VerifyExternalJWT() should fail for invalid signature")
}
}
func TestVerifyExternalJWT_MissingUsernameClaim(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
// Create a token without the 'sub' claim
claims := jwt.MapClaims{
"email": "test@example.com",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token - should fail due to missing 'sub' claim
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
if err == nil {
t.Fatal("VerifyExternalJWT() should fail for missing username claim")
}
}
func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
username := "testuser"
// Create a token with custom username field
claims := jwt.MapClaims{
"username": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token with custom user identifier field
extractedUsername, _, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "username")
if err != nil {
t.Fatalf("VerifyExternalJWT() failed: %v", err)
}
if extractedUsername != username {
t.Errorf("Expected username %s, got %s", username, extractedUsername)
}
}
func TestVerifyExternalJWT_ES256_PublicKeyPEM(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
username := "ec-user"
claims := jwt.MapClaims{
"sub": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tokenString, err := token.SignedString(priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "ES256", "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT: %v", err)
}
if got != username {
t.Errorf("username: got %q want %q", got, username)
}
}
func TestVerifyExternalJWT_RS256_PublicKeyPEM(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
username := "rsa-user"
claims := jwt.MapClaims{
"sub": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tokenString, err := token.SignedString(priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "RS256", "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT: %v", err)
}
if got != username {
t.Errorf("username: got %q want %q", got, username)
}
}
func TestVerifyExternalJWT_ES256_WrongCurveRejected(t *testing.T) {
p256Priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
p384Priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
verifyDER, err := x509.MarshalPKIXPublicKey(&p384Priv.PublicKey)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
verifyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: verifyDER}))
claims := jwt.MapClaims{
"sub": "u",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tokenString, err := token.SignedString(p256Priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
_, _, err = auth.VerifyExternalJWT(tokenString, verifyPEM, "ES256", "sub")
if err == nil {
t.Fatal("expected error when PEM public key curve does not match algorithm")
}
}
func TestVerifyExternalJWT_CertificatePEM(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
tpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "jwt-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
username := "cert-user"
claims := jwt.MapClaims{
"sub": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tokenString, err := token.SignedString(priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
got, _, err := auth.VerifyExternalJWT(tokenString, certPEM, "ES256", "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT: %v", err)
}
if got != username {
t.Errorf("username: got %q want %q", got, username)
}
}
func TestExtractGroupsFromClaims_ArrayOfStrings(t *testing.T) {
claims := map[string]interface{}{
"groups": []string{"admin", "users"},
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 2 {
t.Fatalf("Expected 2 groups, got %d", len(groups))
}
if groups[0] != "admin" || groups[1] != "users" {
t.Errorf("Expected groups [admin, users], got %v", groups)
}
}
func TestExtractGroupsFromClaims_ArrayOfInterfaces(t *testing.T) {
claims := map[string]interface{}{
"groups": []interface{}{"admin", "users"},
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 2 {
t.Fatalf("Expected 2 groups, got %d", len(groups))
}
if groups[0] != "admin" || groups[1] != "users" {
t.Errorf("Expected groups [admin, users], got %v", groups)
}
}
func TestExtractGroupsFromClaims_SingleString(t *testing.T) {
claims := map[string]interface{}{
"groups": "admin",
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 1 {
t.Fatalf("Expected 1 group, got %d", len(groups))
}
if groups[0] != "admin" {
t.Errorf("Expected group admin, got %s", groups[0])
}
}
func TestExtractGroupsFromClaims_MissingClaim(t *testing.T) {
claims := map[string]interface{}{
"email": "test@example.com",
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 0 {
t.Fatalf("Expected 0 groups, got %d", len(groups))
}
}
func TestExtractGroupsFromClaims_CustomClaimName(t *testing.T) {
claims := map[string]interface{}{
"roles": []string{"admin", "editor"},
}
groups := auth.ExtractGroupsFromClaims(claims, "roles")
if len(groups) != 2 {
t.Fatalf("Expected 2 groups, got %d", len(groups))
}
if groups[0] != "admin" || groups[1] != "editor" {
t.Errorf("Expected groups [admin, editor], got %v", groups)
}
}
+10 -8
View File
@@ -67,15 +67,17 @@ func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, err
secretText := key.Secret()
nonce := ""
secretToStore := secretText
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is provided, encrypt the secret.
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
if err != nil {
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
}
}
// set cache so verify can attempt to use it but not require it for user yet.
TotpCache.Set(user.Username, secretText+"||"+nonce)
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
// Use the original base32 secret in the OTP URL, not the encrypted version
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
return url, nil
}
@@ -144,14 +146,14 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
}
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
// get data from cache
cachedSecret, found := TotpCache.Get(user.Username)
if !found && user.TOTPSecret == "" {
return fmt.Errorf("OTP token not found in cache, please generate a new one")
}
useCache := found && user.TOTPSecret == ""
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
if found {
if useCache {
splitSecret := strings.Split(cachedSecret, "||")
if len(splitSecret) < 2 {
return fmt.Errorf("invalid cached OTP token format")
@@ -171,9 +173,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
}
secretToValidate = decryptedSecret
}
// --- END: ADD THIS DECRYPTION LOGIC ---
// Validate the token using the (now plaintext) secret.
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
Period: TOTPPeriod,
Skew: TOTPSkew,
@@ -181,12 +181,13 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
Algorithm: TOTPAlgorithm,
})
if err != nil {
logger.Errorf("error during TOTP validation: %v", err)
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
}
if !valid {
logger.Warningf("Invalid TOTP code for user %s", user.Username)
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" {
if totpSecret != "" || !user.OtpEnabled {
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
user.OtpEnabled = true // Enable OTP for the user
@@ -195,6 +196,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
logger.Error("error updating user with OTP token:", err)
return fmt.Errorf("error updating user with OTP token: %w", err)
}
TotpCache.Delete(user.Username)
} else {
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
}
+70 -50
View File
@@ -23,7 +23,7 @@ var (
)
// return bool to indicate if the program should continue running
func runCLI() bool {
func runCLI() (bool, bool) {
generateYaml()
@@ -53,75 +53,100 @@ func runCLI() bool {
// Show help if requested
if help {
generalUsage()
return false
return false, false
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, dbConfig string
var asAdmin bool
var fsPath, indexPath, ruleCategory, value string
var sourceName, indexPath, ruleCategory, value string
var allow bool
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
setCmd.BoolVar(&asAdmin, "a", false, "Create a user as admin user, used in combination with -u")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
setCmd.StringVar(&fsPath, "f", "", "Real filesystem path (e.g. /mnt/storage) for rule command")
setCmd.StringVar(&sourceName, "s", "", "source name to apply the rule to")
setCmd.StringVar(&indexPath, "p", "", "Index path (e.g. /secret) for rule command")
setCmd.StringVar(&ruleCategory, "r", "", "Rule category: 'user', 'group', or 'all' (for deny only)")
setCmd.StringVar(&value, "v", "", "Value: username or groupname (not required if ruleCategory is 'all')")
setCmd.BoolVar(&allow, "allow", false, "Allow access (default: false, which means deny)")
// Parse subcommand flags only if a subcommand is specified
storeCfg := configPath
cliSubcmd := ""
var setKind string
if len(os.Args) > 1 {
switch os.Args[1] {
case "setup":
createConfig(configPath)
return false
case "set":
if len(os.Args) < 3 {
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
os.Exit(1)
}
subcommand := os.Args[2]
// New pattern: subcommand-based (e.g., "set user" or "set rule")
// Parse flags starting from os.Args[3:] to skip the subcommand
err := setCmd.Parse(os.Args[3:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
switch subcommand {
case "-u":
err := setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
case "rule":
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
default:
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", subcommand)
os.Exit(1)
}
case "version":
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return false
`, version.Version, version.CommitSHA, version.Version)
return false, false
case "setup":
cliSubcmd = "setup"
case "set":
cliSubcmd = "set"
if len(os.Args) < 3 {
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
os.Exit(1)
}
setKind = os.Args[2]
var err error
// Handle old syntax: "set -u user,pass -c config.yaml"
if setKind == "-u" {
err = setCmd.Parse(os.Args[2:])
} else {
// New pattern: subcommand-based (e.g., "set user" or "set rule")
err = setCmd.Parse(os.Args[3:])
}
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
storeCfg = dbConfig
}
}
return true
dbExists := getStore(storeCfg)
switch cliSubcmd {
case "setup":
createConfig(configPath)
return false, dbExists
case "set":
if setKind == "-u" {
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
err := setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false, dbExists
}
switch setKind {
case "rule":
sourceInfo, ok := settings.Config.Server.NameToSource[sourceName] // backend source is path
if !ok {
fmt.Printf("error: invalid source name: %s\n", sourceName)
os.Exit(1)
}
err := setRule(dbConfig, sourceInfo.Path, indexPath, ruleCategory, value, allow)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false, dbExists
default:
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", setKind)
os.Exit(1)
}
return false, dbExists
default:
return true, dbExists
}
}
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
@@ -138,10 +163,6 @@ func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool
if ruleCategory != "all" && value == "" {
return fmt.Errorf("value is required when ruleCategory is 'user' or 'group': use -v <username|groupname>")
}
// Initialize store and settings
_ = getStore(dbConfig) // ignore bool check
// Apply the rule based on allow flag and ruleCategory
var err error
if allow {
@@ -192,7 +213,6 @@ func setUser(dbConfig string, asAdmin bool) error {
}
username := userInfo[0]
password := userInfo[1]
_ = getStore(dbConfig) // ignore bool check
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
+45 -9
View File
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"io/fs"
"os"
"os/signal"
"syscall"
@@ -18,6 +19,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/icons"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -52,10 +54,14 @@ commands:
}
func StartFilebrowser() {
keepGoing := runCLI()
keepGoing, dbExists := runCLI()
if !keepGoing {
return
}
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
if !settings.Config.Server.DisableUpdateCheck {
info, _ := utils.CheckForUpdates()
if info.LatestVersion != "" {
@@ -74,11 +80,6 @@ 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")
@@ -87,6 +88,11 @@ func StartFilebrowser() {
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))
@@ -116,17 +122,23 @@ func StartFilebrowser() {
}
// Initialize shared index database before starting HTTP service
if err := indexing.InitializeIndexDB(); err != nil {
isNewDb, err := indexing.InitializeIndexDB()
if err != nil {
logger.Fatalf("Failed to initialize index database: %v", err)
}
// Set indexing storage for persistence
if store != nil && store.Indexing != nil {
indexing.SetIndexingStorage(store.Indexing)
if isNewDb {
if err := store.Indexing.ResetAllComplexities(); err != nil {
logger.Errorf("Failed to reset index complexities: %v", err)
}
}
}
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false)
go indexing.Initialize(source, false, isNewDb)
}
validateUserInfo(!dbExists)
validateOfficeIntegration()
@@ -175,11 +187,35 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
numWorkers := settings.Config.Server.NumImageProcessors
ffmpeg.SetFFmpegPaths()
// setup disk cache
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
embeddedAssets := fbhttp.GetEmbeddedAssets()
subAssets, err := fs.Sub(embeddedAssets, "embed")
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
fileutils.InitAssetFS(subAssets, true)
} else {
fileutils.InitAssetFS(nil, false)
}
// Start preview service
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
logger.Fatalf("Error starting preview service: %v", err)
}
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
logger.Debugf("Exiftool Enabled : %v", settings.Config.Integrations.Media.ExiftoolPath != "")
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
// Initialize PWA manifest after icons are generated
icons.InitializePWAManifest()
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+29 -8
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"reflect"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -40,10 +41,12 @@ func validateUserInfo(newDB bool) {
if updateSidebarLinks(user) {
updateUser = true
}
if updateTokens(user) {
updateUser = true
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
if user.Username == adminUser && adminPass != "" && passwordEnabled {
if user.Username == adminUser && adminPass != "" && user.LoginMethod == users.LoginMethodPassword {
logger.Info("Resetting admin user to default username and password.")
user.Permissions.Admin = true
user.Password = settings.Config.Auth.AdminPassword
@@ -59,7 +62,11 @@ func validateUserInfo(newDB bool) {
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
}
}
err := store.Users.Save(user, changePass, true)
fields := []string{"Scopes", "SidebarLinks", "Tokens", "Permissions", "Preview", "ShowFirstLogin", "LoginMethod", "Version"}
if changePass {
fields = append(fields, "Password")
}
err := store.Users.Update(user, true, fields...)
if err != nil {
logger.Errorf("could not update user: %v", err)
}
@@ -90,7 +97,6 @@ func updateUserScopes(user *users.User) bool {
} else {
continue
}
newScopes = append(newScopes, users.SourceScope{
Name: src.Path,
Scope: existingScope.Scope,
@@ -119,7 +125,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 >= 1 {
return false
}
updateUser := true
@@ -165,7 +171,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = 1
user.Version = 2
if updateUser {
createBackup = true
}
@@ -197,7 +203,7 @@ func updateSidebarLinks(user *users.User) bool {
validSourceLinksCount := 0
for _, link := range user.SidebarLinks {
if link.Category == "source" {
if strings.HasPrefix(link.Category, "source") {
sourceLinksCount++
// Check if this source still exists
if link.SourceName != "" {
@@ -220,7 +226,7 @@ func updateSidebarLinks(user *users.User) bool {
// Remove all existing source links
newLinks := []users.SidebarLink{}
for _, link := range user.SidebarLinks {
if link.Category != "source" {
if !strings.HasPrefix(link.Category, "source") {
newLinks = append(newLinks, link)
}
}
@@ -246,3 +252,18 @@ func updateSidebarLinks(user *users.User) bool {
// User has at least one valid source link, no update needed
return false
}
func updateTokens(user *users.User) bool {
if user.Version >= 2 {
return false
}
if user.ApiKeys != nil {
user.Tokens = make(map[string]users.AuthToken)
for name, token := range user.ApiKeys {
token.Token = token.Key
user.Tokens[name] = token
}
}
user.Version = 2
return true
}
-1
View File
@@ -156,7 +156,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}
+2 -1
View File
@@ -19,9 +19,10 @@ var (
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not configured")
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
ErrNotViewable = errors.New("directory or item is not viewable")
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
@@ -0,0 +1,192 @@
package settings
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// TestApplyUserDefaults_copiesUserDefaultsOntoUser guards against drift between
// [UserDefaults] and [ApplyUserDefaults] (regression for #2278-style bugs).
func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
saved := Config
defer func() { Config = saved }()
dark := false
dlFalse := false
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{
EditorQuickSave: true,
HideSidebarFileActions: true,
DisableQuickToggles: true,
DisableSearchOptions: true,
StickySidebar: false,
HideFilesInTree: true,
DarkMode: &dark,
Locale: "de",
ViewMode: "list",
SingleClick: true,
ShowHidden: true,
DateFormat: true,
GallerySize: 7,
ThemeColor: "var(--red)",
QuickDownload: true,
DisablePreviewExt: ".exe",
DisableViewingExt: ".bat",
DisableSettings: true,
LockPassword: true,
DisableUpdateNotifications: true,
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
DisableOfficePreviewExt: "legacy",
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
DebugOffice: true,
PreferEditorForMarkdown: true,
LoginMethod: "password",
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
DownloadChunkSize: 2,
},
Permissions: UserDefaultsPermissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: &dlFalse,
},
Preview: UserDefaultsPreview{
DisableHideSidebar: true,
Image: boolPtr(false),
Video: boolPtr(false),
Audio: boolPtr(false),
MotionVideoPreview: boolPtr(false),
Office: boolPtr(false),
PopUp: boolPtr(false),
AutoplayMedia: boolPtr(false),
DefaultMediaPlayer: true,
Folder: boolPtr(false),
Models: boolPtr(false),
},
},
}
u := &users.User{Username: "alice"}
ApplyUserDefaults(u)
want := users.User{
DisableSettings: true,
LockPassword: true,
LoginMethod: users.LoginMethodPassword,
Permissions: users.Permissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: false,
},
NonAdminEditable: users.NonAdminEditable{
EditorQuickSave: true,
HideSidebarFileActions: true,
DisableQuickToggles: true,
DisableSearchOptions: true,
StickySidebar: false,
HideFilesInTree: true,
DarkMode: false,
Locale: "de",
ViewMode: "list",
SingleClick: true,
ShowHidden: true,
DateFormat: true,
GallerySize: 7,
ThemeColor: "var(--red)",
QuickDownload: true,
DisablePreviewExt: ".exe",
DisableViewingExt: ".bat",
DisableUpdateNotifications: true,
DisableOfficePreviewExt: "legacy",
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
DebugOffice: true,
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
PreferEditorForMarkdown: true,
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
DownloadChunkSize: 2,
},
Preview: users.Preview{
DisableHideSidebar: true,
Image: false,
Video: false,
Audio: false,
MotionVideoPreview: false,
Office: false,
PopUp: false,
AutoplayMedia: false,
DefaultMediaPlayer: true,
Folder: false,
Models: false,
},
},
}
if diff := cmp.Diff(want.Permissions, u.Permissions); diff != "" {
t.Fatalf("Permissions mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(want.NonAdminEditable, u.NonAdminEditable); diff != "" {
t.Fatalf("NonAdminEditable mismatch (-want +got):\n%s", diff)
}
if u.DisableSettings != want.DisableSettings {
t.Fatalf("DisableSettings: got %v want %v", u.DisableSettings, want.DisableSettings)
}
if u.LockPassword != want.LockPassword {
t.Fatalf("LockPassword: got %v want %v", u.LockPassword, want.LockPassword)
}
if u.LoginMethod != want.LoginMethod {
t.Fatalf("LoginMethod: got %q want %q", u.LoginMethod, want.LoginMethod)
}
}
func TestApplyUserDefaults_setsLoginMethodWhenEmpty(t *testing.T) {
saved := Config
defer func() { Config = saved }()
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{LoginMethod: "jwt"},
}
u := &users.User{Username: "x"}
ApplyUserDefaults(u)
if u.LoginMethod != users.LoginMethodJwt {
t.Fatalf("LoginMethod: got %q want jwt", u.LoginMethod)
}
}
func TestApplyUserDefaults_preservesLoginMethodWhenAlreadySet(t *testing.T) {
saved := Config
defer func() { Config = saved }()
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{LoginMethod: "password"},
}
u := &users.User{Username: "x", LoginMethod: users.LoginMethodProxy}
ApplyUserDefaults(u)
if u.LoginMethod != users.LoginMethodProxy {
t.Fatalf("LoginMethod: got %q want proxy", u.LoginMethod)
}
}
+180 -17
View File
@@ -6,8 +6,11 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
ldap "github.com/go-ldap/ldap/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -29,6 +32,18 @@ func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
return t.base.RoundTrip(reqCopy)
}
// AuthCommon contains fields shared across multiple authentication methods
type AuthCommon struct {
Enabled bool `json:"enabled"` // whether to enable this authentication method.
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges
UserGroups []string `json:"userGroups"` // if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification (insecure, for testing only)
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"` // deprecated: always true for supported authentication methods
}
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"`
@@ -44,6 +59,8 @@ type LoginMethods struct {
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"`
LdapAuth LdapConfig `json:"ldap" validate:"omitempty"`
JwtAuth JwtAuthConfig `json:"jwt" validate:"omitempty"`
}
type PasswordAuthConfig struct {
@@ -55,10 +72,8 @@ type PasswordAuthConfig struct {
}
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.
AuthCommon `json:",inline"`
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
}
type Recaptcha struct {
@@ -69,19 +84,65 @@ type Recaptcha struct {
// 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
AuthCommon `json:",inline"`
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
Provider *oidc.Provider `json:"-"` // OIDC provider
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
}
type LdapConfig struct {
AuthCommon `json:",inline"`
Server string `json:"server"` // scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
BaseDN string `json:"baseDN"` // LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
UserDN string `json:"userDN"` // Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
UserPassword string `json:"userPassword"` // Bind password for service account
UserFilter string `json:"userFilter"` // Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
Port int `json:"-"` // derived from server
Scheme string `json:"-"` // derived from server
Host string `json:"-"` // derived from server
}
// JwtAuthConfig configures external JWT token authentication
// Similar to Grafana's JWT auth: verifies HS* tokens with a shared secret, or RS*/ES* with a PEM public key (or certificate)
// The query parameter is hardcoded to "jwt" (e.g. ?jwt=<token>)
type JwtAuthConfig struct {
AuthCommon `json:",inline"`
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
Secret string `json:"secret"` // Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
}
// ValidateLdapAuth checks LDAP config and sets defaults. Call when LDAP is enabled.
func ValidateLdapAuth() error {
ldapCfg := &Config.Auth.Methods.LdapAuth
if ldapCfg.Server == "" {
return fmt.Errorf("LDAP server is required when LDAP is enabled")
}
scheme, host, port, err := parseLdapServer(ldapCfg.Server)
if err != nil {
return fmt.Errorf("LDAP server is invalid: %w", err)
}
ldapCfg.Server = fmt.Sprintf("%s://%s:%d", scheme, host, port)
ldapCfg.Scheme = scheme
ldapCfg.Host = host
ldapCfg.Port = port
if ldapCfg.BaseDN == "" {
return fmt.Errorf("LDAP baseDN is required when LDAP is enabled")
}
if ldapCfg.UserDN == "" {
return fmt.Errorf("LDAP userDN (bind DN) is required when LDAP is enabled")
}
if ldapCfg.UserFilter == "" {
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
}
if err := verifyLdapConnection(); err != nil {
logger.Fatalf("LDAP connection check failed: %v", err)
}
return nil
}
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
@@ -133,3 +194,105 @@ func validateOidcAuth() error {
return nil
}
func parseLdapServer(server string) (scheme, host string, port int, err error) {
parts := strings.Split(server, "://")
if len(parts) != 2 {
return "", "", 0, fmt.Errorf("invalid LDAP server: %s", server)
}
scheme = strings.ToLower(parts[0])
if scheme != "ldap" && scheme != "ldaps" {
return "", "", 0, fmt.Errorf("invalid LDAP scheme: %s", scheme)
}
hostParts := strings.Split(parts[1], ":")
host = strings.TrimSuffix(hostParts[0], "/")
if len(hostParts) > 1 && hostParts[1] != "" {
port, err = strconv.Atoi(strings.TrimSuffix(hostParts[1], "/"))
if err != nil || port == 0 {
return "", "", 0, fmt.Errorf("invalid LDAP port: %s", hostParts[1])
}
} else {
if scheme == "ldaps" {
port = 636
} else {
port = 389
}
}
return scheme, host, port, nil
}
// VerifyLdapConnection tests that the LDAP server is reachable, can bind (if credentials set), and responds to a search.
func verifyLdapConnection() error {
if Config.Auth.Methods.LdapAuth.Server == "" {
return fmt.Errorf("LDAP server is required when LDAP is enabled")
}
if Config.Auth.Methods.LdapAuth.BaseDN == "" {
return fmt.Errorf("LDAP baseDN is required for connection verification")
}
var opts []ldap.DialOpt
scheme := Config.Auth.Methods.LdapAuth.Scheme
host := Config.Auth.Methods.LdapAuth.Host
port := Config.Auth.Methods.LdapAuth.Port
if Config.Auth.Methods.LdapAuth.DisableVerifyTLS && scheme == "ldaps" {
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
}
fullURL := fmt.Sprintf("%s://%s:%d", scheme, host, port)
conn, err := ldap.DialURL(fullURL, opts...)
if err != nil {
return fmt.Errorf("LDAP connection failed (is the LDAP outpost running at %s? use port 389 for LDAP, 636 for LDAPS; this is not the HTTP port): %w", fullURL, err)
}
defer conn.Close()
if Config.Auth.Methods.LdapAuth.UserDN != "" && Config.Auth.Methods.LdapAuth.UserPassword != "" {
if err = conn.Bind(Config.Auth.Methods.LdapAuth.UserDN, Config.Auth.Methods.LdapAuth.UserPassword); err != nil {
return fmt.Errorf("LDAP bind failed (check userDN and userPassword): %w", err)
}
defer func() { _ = conn.Unbind() }()
}
// Dummy search to verify the server responds to LDAP search (same path that fails at login if misconfigured).
searchRequest := ldap.NewSearchRequest(
Config.Auth.Methods.LdapAuth.BaseDN,
ldap.ScopeBaseObject,
ldap.NeverDerefAliases,
0,
0,
false,
"(objectClass=*)",
[]string{"1.1"}, // no attributes
nil,
)
_, err = conn.Search(searchRequest)
if err != nil {
return fmt.Errorf("LDAP search test failed (server may require StartTLS or bind before search): %w", err)
}
return nil
}
// ValidateJwtAuth checks JWT config and sets defaults. Call when JWT auth is enabled.
func ValidateJwtAuth() error {
jwtCfg := &Config.Auth.Methods.JwtAuth
if jwtCfg.Secret == "" {
return fmt.Errorf("JWT secret is required when JWT auth is enabled")
}
if jwtCfg.Header == "" {
jwtCfg.Header = "X-JWT-Assertion"
}
if jwtCfg.Algorithm == "" {
jwtCfg.Algorithm = "HS256"
}
if jwtCfg.GroupsClaim == "" {
jwtCfg.GroupsClaim = "groups"
}
if jwtCfg.UserIdentifier == "" {
jwtCfg.UserIdentifier = "sub"
}
// Validate algorithm
validAlgos := map[string]bool{
"HS256": true, "HS384": true, "HS512": true,
"RS256": true, "RS384": true, "RS512": true,
"ES256": true, "ES384": true, "ES512": true,
}
if !validAlgos[jwtCfg.Algorithm] {
return fmt.Errorf("unsupported JWT algorithm: %s. Supported: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512", jwtCfg.Algorithm)
}
return nil
}
+221
View File
@@ -0,0 +1,221 @@
package settings
import (
"strings"
"testing"
)
func TestParseLdapServer(t *testing.T) {
tests := []struct {
name string
server string
wantScheme string
wantHost string
wantPort int
wantErr bool
}{
{
name: "ldap with host and port",
server: "ldap://localhost:389",
wantScheme: "ldap",
wantHost: "localhost",
wantPort: 389,
wantErr: false,
},
{
name: "ldaps with host and port",
server: "ldaps://ldap.example.com:636",
wantScheme: "ldaps",
wantHost: "ldap.example.com",
wantPort: 636,
wantErr: false,
},
{
name: "ldap with trailing slash",
server: "ldap://host:389/",
wantScheme: "ldap",
wantHost: "host",
wantPort: 389,
wantErr: false,
},
{
name: "ldap port not provided defaults to 389",
server: "ldap://localhost",
wantScheme: "ldap",
wantHost: "localhost",
wantPort: 389,
wantErr: false,
},
{
name: "ldaps port not provided defaults to 636",
server: "ldaps://ldap.example.com",
wantScheme: "ldaps",
wantHost: "ldap.example.com",
wantPort: 636,
wantErr: false,
},
{
name: "port provided but invalid returns error",
server: "ldap://host:bad",
wantErr: true,
},
{
name: "port provided but invalid (ldaps) returns error",
server: "ldaps://host:xyz",
wantErr: true,
},
{
name: "port zero returns error",
server: "ldap://host:0",
wantErr: true,
},
{
name: "empty server",
server: "",
wantErr: true,
},
{
name: "no scheme",
server: "localhost:389",
wantErr: true,
},
{
name: "invalid scheme",
server: "http://localhost:389",
wantErr: true,
},
{
name: "invalid scheme ftp",
server: "ftp://localhost:389",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotScheme, gotHost, gotPort, err := parseLdapServer(tt.server)
if (err != nil) != tt.wantErr {
t.Errorf("parseLdapServer() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if gotScheme != tt.wantScheme {
t.Errorf("parseLdapServer() scheme = %v, want %v", gotScheme, tt.wantScheme)
}
if gotHost != tt.wantHost {
t.Errorf("parseLdapServer() host = %v, want %v", gotHost, tt.wantHost)
}
if gotPort != tt.wantPort {
t.Errorf("parseLdapServer() port = %v, want %v", gotPort, tt.wantPort)
}
}
})
}
}
func TestVerifyLdapConnection_ValidationOnly(t *testing.T) {
// Save and restore global config so we don't affect other tests
saved := Config.Auth.Methods.LdapAuth
defer func() { Config.Auth.Methods.LdapAuth = saved }()
t.Run("empty server returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "",
BaseDN: "dc=test,dc=local",
}
err := verifyLdapConnection()
if err == nil {
t.Fatal("verifyLdapConnection() expected error for empty server")
}
if !strings.Contains(err.Error(), "LDAP server is required") {
t.Errorf("verifyLdapConnection() error = %v, want message containing 'LDAP server is required'", err)
}
})
t.Run("empty baseDN returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "ldap://localhost:389",
Scheme: "ldap",
Host: "localhost",
Port: 389,
BaseDN: "",
}
err := verifyLdapConnection()
if err == nil {
t.Fatal("verifyLdapConnection() expected error for empty baseDN")
}
if !strings.Contains(err.Error(), "baseDN is required") {
t.Errorf("verifyLdapConnection() error = %v, want message containing 'baseDN is required'", err)
}
})
}
func TestValidateLdapAuth_ConfigValidation(t *testing.T) {
saved := Config.Auth.Methods.LdapAuth
defer func() { Config.Auth.Methods.LdapAuth = saved }()
t.Run("empty server returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "",
BaseDN: "dc=test,dc=local",
UserDN: "cn=admin,dc=test,dc=local",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for empty server")
}
if !strings.Contains(err.Error(), "server is required") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
t.Run("invalid server URL returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "not-a-valid-ldap-url",
BaseDN: "dc=test,dc=local",
UserDN: "cn=admin,dc=test,dc=local",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for invalid server URL")
}
if !strings.Contains(err.Error(), "invalid") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
t.Run("empty baseDN returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "ldap://localhost:389",
BaseDN: "",
UserDN: "cn=admin,dc=test,dc=local",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for empty baseDN")
}
if !strings.Contains(err.Error(), "baseDN is required") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
t.Run("empty userDN returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "ldap://localhost:389",
BaseDN: "dc=test,dc=local",
UserDN: "",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for empty userDN")
}
if !strings.Contains(err.Error(), "userDN") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
}
+112 -107
View File
@@ -6,6 +6,7 @@ import (
"io"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
@@ -55,7 +56,7 @@ func Initialize(configFile string) {
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
setupUrls()
setupFrontend(false)
setupMedia()
setupMedia(false)
}
func setupServer() {
@@ -92,7 +93,7 @@ func setupFs() {
Config.Server.Filesystem.CreateDirectoryPermission = "755"
dirPermOctal, _ = strconv.ParseUint("755", 8, 32)
}
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
fileutils.SetFsPermissions(uint32(filePermOctal), uint32(dirPermOctal))
// Perform mandatory cache directory speed test
testCacheDirSpeed()
@@ -280,42 +281,53 @@ func setupFrontend(generate bool) {
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
func setupMedia(generate bool) {
// Save user's explicit config before applying defaults
userImageConfig := make(map[ImagePreviewType]*bool)
for k, v := range Config.Integrations.Media.Convert.ImagePreview {
userImageConfig[k] = v
}
// 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
// Re-initialize with defaults (gets wiped out during YAML unmarshal)
Config.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
Config.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
Config.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
// Apply user overrides (only for keys explicitly set in YAML)
for k, v := range userImageConfig {
Config.Integrations.Media.Convert.ImagePreview[k] = v
}
// User has explicitly configured some video preview settings
// Start with all enabled, then apply user overrides
userConfig := make(map[VideoPreviewType]bool)
// VideoPreview: Merge user config with defaults
// Save user's explicit config from YAML
userVideoConfig := make(map[VideoPreviewType]*bool)
for k, v := range Config.Integrations.Media.Convert.VideoPreview {
userConfig[k] = v
userVideoConfig[k] = v
}
// Reset to defaults (all enabled)
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
// Re-initialize with defaults (all enabled)
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
Config.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
}
// Apply user overrides (only for explicitly set values)
for k, v := range userConfig {
// Apply user overrides (only for keys explicitly set in YAML)
for k, v := range userVideoConfig {
Config.Integrations.Media.Convert.VideoPreview[k] = v
}
// Resolve exiftool path once at startup: validate user path or discover via PATH
if Config.Integrations.Media.ExiftoolPath != "" && !generate {
if err := exec.Command(Config.Integrations.Media.ExiftoolPath, "-ver").Run(); err != nil {
logger.Warningf("exiftool path is invalid or not executable: %q (%v); disabling exiftool", Config.Integrations.Media.ExiftoolPath, err)
Config.Integrations.Media.ExiftoolPath = ""
}
}
if Config.Integrations.Media.ExiftoolPath == "" && !generate {
if path, err := exec.LookPath("exiftool"); err == nil && path != "" {
Config.Integrations.Media.ExiftoolPath = path
}
}
}
func setupSources(generate bool) {
@@ -338,11 +350,7 @@ func setupSources(generate bool) {
if name == "\\" {
name = strings.Split(realPath, ":")[0]
}
if generate {
source.Path = generatorPath // use placeholder path
} else {
source.Path = realPath // use absolute path
}
source.Path = realPath // use absolute path
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
if ok {
@@ -351,6 +359,10 @@ func setupSources(generate bool) {
source.Name = name
}
}
if generate {
source.Path = generatorPath // use placeholder path
source.Name = "Source Name"
}
modifyExcludeInclude(source)
setConditionals(source)
if source.Config.DefaultUserScope == "" {
@@ -422,20 +434,34 @@ func setupAuth(generate bool) {
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 {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
err := validateOidcAuth()
if err != nil && !generate {
logger.Fatalf("Error validating OIDC auth: %v", err)
}
logger.Info("OIDC Auth configured successfully")
}
if Config.Auth.Methods.LdapAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "ldap")
err := ValidateLdapAuth()
if err != nil && !generate {
logger.Fatalf("Error validating LDAP auth: %v", err)
}
logger.Info("LDAP Auth configured successfully")
}
if Config.Auth.Methods.JwtAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "jwt")
err := ValidateJwtAuth()
if err != nil && !generate {
logger.Fatalf("Error validating JWT auth: %v", err)
}
logger.Info("JWT Auth configured successfully")
}
// use password auth as default if no auth methods are set
if len(Config.Auth.AuthMethods) == 0 {
@@ -624,6 +650,11 @@ func loadEnvConfig() {
logger.Info("Using ReCaptcha Secret from FILEBROWSER_RECAPTCHA_SECRET environment variable")
}
ldapUserPassword := os.Getenv("FILEBROWSER_LDAP_USER_PASSWORD")
if ldapUserPassword != "" {
Config.Auth.Methods.LdapAuth.UserPassword = ldapUserPassword
logger.Info("Using LDAP bind password from FILEBROWSER_LDAP_USER_PASSWORD environment variable")
}
}
func setDefaults(generate bool) Settings {
@@ -636,14 +667,9 @@ func setDefaults(generate bool) Settings {
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`.")
logger.Warning("database file could not be found. If this is unexpected, please set the FILEBROWSER_DATABASE environment variable to the correct path.")
}
s := Settings{
Server: Server{
@@ -653,13 +679,14 @@ func setDefaults(generate bool) Settings {
Database: database,
SourceMap: map[string]*Source{},
NameToSource: map[string]*Source{},
MaxArchiveSizeGB: 50,
CacheDir: "tmp",
MaxArchiveSizeGB: 20,
IndexSqlConfig: IndexSqlConfig{
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
StartupIntegrityCheck: IndexStartupIntegrityQuickCheck,
},
Filesystem: Filesystem{
CreateFilePermission: "644",
@@ -680,18 +707,20 @@ func setDefaults(generate bool) Settings {
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)",
DeleteAfterArchive: true,
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
HideFilesInTree: false,
PreferEditorForMarkdown: false,
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: UserDefaultsPermissions{
Modify: false,
Share: false,
@@ -700,37 +729,37 @@ func setDefaults(generate bool) Settings {
Download: boolPtr(true), // defaults to true
},
Preview: UserDefaultsPreview{
HighQuality: boolPtr(true),
Image: boolPtr(true),
Video: boolPtr(true),
Audio: boolPtr(true),
MotionVideoPreview: boolPtr(true),
Office: boolPtr(true),
PopUp: boolPtr(true),
AutoplayMedia: boolPtr(true),
Folder: boolPtr(true),
Models: boolPtr(true),
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
UploadChunkSize: 10, // 10MB
MaxConcurrent: 10,
UploadChunkSize: 10, // 10MB
DownloadChunkSize: 0, // 0MB, default to no chunking
},
},
}
// 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)
// Initialize ImagePreview map with defaults
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
s.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
s.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
// Initialize VideoPreview map with defaults (all enabled)
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
for _, t := range AllVideoPreviewTypes {
s.Integrations.Media.Convert.VideoPreview[t] = true
s.Integrations.Media.Convert.VideoPreview[t] = boolPtr(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) {
func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) {
// Get absolute path
absolutePath, err = filepath.Abs(configPath)
if err != nil {
@@ -738,16 +767,11 @@ func validateCustomImage(configPath, imageName string, maxSize int64, allowedFor
}
// Check if file exists
stat, err := os.Stat(absolutePath)
_, 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
@@ -766,8 +790,7 @@ func validateCustomImage(configPath, imageName string, maxSize int64, allowedFor
func loadCustomFavicon() {
const imageName = "favicon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".ico", ".png", ".svg"}
allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"}
// Set default embedded favicon path
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
@@ -780,7 +803,7 @@ func loadCustomFavicon() {
}
// Validate custom favicon
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats)
if err != nil {
logger.Warningf("Custom favicon validation failed: %v, using default", err)
Config.Frontend.Favicon = ""
@@ -794,20 +817,21 @@ func loadCustomFavicon() {
Env.FaviconPath = validatedPath
Env.FaviconIsCustom = true
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
// PWA and platform PNGs are generated at server startup from this path (SVG uses a raster
// sidecar next to the .svg when present; see icons.GeneratePWAIcons).
}
// PWAIconsCacheDir is where startup icon generation writes PNGs (under the server cache dir).
func PWAIconsCacheDir() string {
return filepath.Join(Config.Server.CacheDir, "icons")
}
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
}
// Set default embedded icon path - just use favicon.svg (light/dark handled by CSS)
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg"
// Check if a custom login icon path is configured
if Config.Frontend.LoginIcon == "" {
@@ -817,7 +841,7 @@ func loadLoginIcon() {
}
// Validate custom login icon
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats)
if err != nil {
logger.Warningf("Custom login icon validation failed: %v, using default", err)
Env.LoginIconPath = Env.LoginIconEmbeddedPath
@@ -853,26 +877,7 @@ func setConditionals(config *Source) {
IgnoreAllZeroSizeFolders: false,
IgnoreAllSymlinks: false,
IndexingDisabled: false,
}
// backwards compatibility
if config.Config.Conditionals.Hidden {
logger.Warning("source.conditionals.hidden is deprecated, use source.rules instead")
resolved.IgnoreAllHidden = true
}
// Backwards compatibility: if old format fields are set, treat as global rules
if config.Config.Conditionals.IgnoreHidden {
logger.Warning("source.conditionals.ignoreHidden is deprecated, use source.rules instead")
resolved.IgnoreAllHidden = true
}
if config.Config.DisableIndexing {
logger.Warning("source.disableIndexing is deprecated, use source.rules instead")
resolved.IndexingDisabled = true
}
if config.Config.Conditionals.ZeroSizeFolders {
logger.Warning("source.conditionals.zeroSizeFolders is deprecated, use source.rules instead")
resolved.IgnoreAllZeroSizeFolders = true
NoRules: len(rules) == 0,
}
// Process all rules and infer global flags from root-level rules
+16 -2
View File
@@ -1,11 +1,25 @@
package settings
func CanConvertImage(ext string) bool {
return MediaEnabled() && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
if !MediaEnabled() {
return false
}
val := Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
}
func CanConvertVideo(ext string) bool {
return MediaEnabled() && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
if !MediaEnabled() {
return false
}
val := Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
}
func MediaEnabled() bool {
+222 -127
View File
@@ -14,6 +14,7 @@ import (
"sort"
"strings"
"golang.org/x/tools/go/packages"
"gopkg.in/yaml.v3"
)
@@ -111,74 +112,93 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
return comments, secrets, deprecated, nil
}
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
absDir, err := filepath.Abs(dir)
if err != nil {
return nil, nil, nil, err
}
var files []*ast.File
cfg := &packages.Config{
Mode: packages.NeedSyntax | packages.NeedFiles,
Dir: absDir,
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
return parser.ParseFile(fset, filename, src, parser.ParseComments)
},
}
pkgs, err := packages.Load(cfg, ".")
if err == nil && len(pkgs) > 0 {
for _, pkg := range pkgs {
files = append(files, pkg.Syntax...)
}
}
if len(files) == 0 {
// Fallback for directories outside a module (e.g. test temp dirs): parse .go files directly
files, err = parseDirGoFiles(absDir)
if err != nil || len(files) == 0 {
return nil, nil, nil, err
}
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
for _, file := range files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
name := field.Names[0].Name
var parts []string
var fullComment string
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
}
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
}
}
}
@@ -187,6 +207,29 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
return comments, secrets, deprecated, nil
}
// parseDirGoFiles parses all .go files in dir and returns their ASTs. Used when the directory
// is not inside a Go module (e.g. test temp dirs) so packages.Load would fail.
func parseDirGoFiles(dir string) ([]*ast.File, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
var files []*ast.File
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
continue
}
path := filepath.Join(dir, e.Name())
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
files = append(files, f)
}
return files, nil
}
// PathToTypeField represents a mapping from YAML path to Go type and field info
type PathToTypeField struct {
TypeName string
@@ -518,6 +561,130 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
currentField := v.Field(i)
// Check if this is an inline/embedded field
yamlTag := sf.Tag.Get("yaml")
jsonTag := sf.Tag.Get("json")
isInline := strings.Contains(yamlTag, ",inline") || strings.Contains(jsonTag, ",inline") || sf.Anonymous
// If field is inline, merge its content directly into parent
if isInline && sf.Type.Kind() == reflect.Struct {
// Get the embedded struct's fields and add them directly to this level
embeddedType := sf.Type
embeddedValue := currentField
embeddedTypeName := embeddedType.Name()
for j := 0; j < embeddedType.NumField(); j++ {
embeddedField := embeddedType.Field(j)
// Skip unexported fields
if embeddedField.PkgPath != "" {
continue
}
if embeddedYamlTag := embeddedField.Tag.Get("yaml"); embeddedYamlTag == "-" {
continue
}
if embeddedJsonTag := embeddedField.Tag.Get("json"); embeddedJsonTag == "-" {
continue
}
// Skip deprecated fields
if len(deprecated) > 0 && deprecated[embeddedTypeName][embeddedField.Name] {
continue
}
embeddedFieldValue := embeddedValue.Field(j)
// If we have defaults, compare and skip if values match
if defaults.IsValid() && i < defaults.NumField() {
defaultInlineField := defaults.Field(i)
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
defaultEmbeddedField := defaultInlineField.Field(j)
embeddedCurrentValue := embeddedFieldValue.Interface()
embeddedDefaultValue := defaultEmbeddedField.Interface()
if reflect.DeepEqual(embeddedCurrentValue, embeddedDefaultValue) {
continue
}
}
}
// Determine key name for embedded field
embeddedYamlTag := embeddedField.Tag.Get("yaml")
embeddedJsonTag := embeddedField.Tag.Get("json")
var embeddedKeyName string
if embeddedYamlTag != "" && embeddedYamlTag != "-" {
embeddedKeyName = strings.Split(embeddedYamlTag, ",")[0]
} else if embeddedJsonTag != "" && embeddedJsonTag != "-" {
embeddedKeyName = strings.Split(embeddedJsonTag, ",")[0]
} else {
embeddedKeyName = embeddedField.Name
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: embeddedKeyName}
// Attach comments for embedded field
var parts []string
var comment string
if cm := comm[embeddedTypeName][embeddedField.Name]; cm != "" {
comment = cm
}
if comment != "" {
parts = append(parts, comment)
}
if len(comm) > 0 {
if vt := embeddedField.Tag.Get("validate"); vt != "" {
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
}
}
if len(parts) > 0 {
keyNode.LineComment = strings.Join(parts, " ")
}
// Build value node for embedded field
var valNode *yaml.Node
var err error
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if secrets[embeddedTypeName][embeddedField.Name] && !generateConfig {
fieldValue := embeddedFieldValue.Interface()
if str, ok := fieldValue.(string); ok && str == "" {
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "",
Style: yaml.DoubleQuotedStyle,
}
} else {
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "**hidden**",
Style: yaml.DoubleQuotedStyle,
}
}
} else {
var defaultEmbeddedField reflect.Value
if defaults.IsValid() && i < defaults.NumField() {
defaultInlineField := defaults.Field(i)
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
defaultEmbeddedField = defaultInlineField.Field(j)
}
}
valNode, err = buildNodeWithDefaults(embeddedFieldValue, comm, defaultEmbeddedField, secrets, deprecated)
if err != nil {
return nil, err
}
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
// Skip adding the embedded struct itself as a field
continue
}
// If we have defaults, compare and skip if values match
if defaults.IsValid() && i < defaults.NumField() {
defaultField := defaults.Field(i)
@@ -532,8 +699,6 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
}
// determine key: yaml tag > json tag > field name
yamlTag := sf.Tag.Get("yaml")
jsonTag := sf.Tag.Get("json")
var keyName string
if yamlTag != "" && yamlTag != "-" {
keyName = strings.Split(yamlTag, ",")[0]
@@ -649,11 +814,6 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
}
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Special handling for ImagePreview maps - show all supported types
if isImagePreviewMap(v) {
return buildImagePreviewMap(v, comm, secrets, deprecated)
}
// Get and sort map keys alphabetically for consistent output
mapKeys := v.MapKeys()
keyStrings := make([]string, len(mapKeys))
@@ -707,36 +867,21 @@ func GenerateYaml() {
{
Path: ".",
Config: SourceConfig{
Conditionals: ConditionalFilter{
ItemRules: []ConditionalRule{
{}, // Add empty sample rule to show all fields in generated YAML
},
Rules: []ConditionalRule{
{}, // Add empty sample rule to show all fields in generated YAML
},
},
},
}
setupLogging()
setupFs()
setupAuth(true)
// Save original paths before setupSources modifies them (for YAML generation)
originalPaths := make(map[*Source]string)
for _, source := range Config.Server.Sources {
originalPaths[source] = source.Path
}
setupSources(true)
setupUrls()
setupMedia()
setupMedia(true)
setupFrontend(true)
// Restore original paths so the YAML output has the correct paths, not the placeholder
for _, source := range Config.Server.Sources {
if originalPath, ok := originalPaths[source]; ok {
source.Path = originalPath
}
}
output := "../frontend/public/config.generated.yaml" // "output YAML file"
// Generate YAML with comments enabled, full config, and deprecated fields filtered
@@ -1136,53 +1281,3 @@ func AlignComments(input string) string {
return strings.Join(outputLines, "\n")
}
// isImagePreviewMap checks if the given map is an ImagePreview map
func isImagePreviewMap(v reflect.Value) bool {
if v.Kind() != reflect.Map {
return false
}
// Check if the map key type is ImagePreviewType
keyType := v.Type().Key()
return keyType.Name() == "ImagePreviewType"
}
// buildImagePreviewMap builds a YAML node for ImagePreview maps, showing all supported types
func buildImagePreviewMap(v reflect.Value, comm CommentsMap, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Get all supported image preview types from the global slice
for _, imageType := range AllImagePreviewTypes {
typeName := string(imageType)
// Create key node
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: typeName,
}
// Get the value from the map, defaulting to false if not present
var value bool
if v.Len() > 0 {
// Look for the key in the map
for _, mapKey := range v.MapKeys() {
if mapKey.String() == typeName {
value = v.MapIndex(mapKey).Bool()
break
}
}
}
// Create value node
valNode := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!bool",
Value: fmt.Sprintf("%t", value),
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
return mapNode, nil
}
+59 -37
View File
@@ -65,49 +65,71 @@ func AdminPerms() users.Permissions {
}
// Apply applies the default options to a user.
// Keep this in sync with [UserDefaults]: every user-facing field there should be copied
// here (except DefaultScopes, which is wired through source config rather than this helper).
func ApplyUserDefaults(u *users.User) {
u.StickySidebar = Config.UserDefaults.StickySidebar
u.DisableSettings = Config.UserDefaults.DisableSettings
d := Config.UserDefaults
// Handle DarkMode with default - dereference pointer from config
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
u.DisableSettings = d.DisableSettings
u.LockPassword = d.LockPassword
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
u.EditorQuickSave = d.EditorQuickSave
u.HideSidebarFileActions = d.HideSidebarFileActions
u.DisableQuickToggles = d.DisableQuickToggles
u.DisableSearchOptions = d.DisableSearchOptions
u.StickySidebar = d.StickySidebar
u.HideFilesInTree = d.HideFilesInTree
u.DarkMode = boolValueOrDefault(d.DarkMode, true)
u.Locale = d.Locale
u.ViewMode = d.ViewMode
u.SingleClick = d.SingleClick
u.ShowHidden = d.ShowHidden
u.DateFormat = d.DateFormat
u.GallerySize = d.GallerySize
u.ThemeColor = d.ThemeColor
u.QuickDownload = d.QuickDownload
u.DisablePreviewExt = d.DisablePreviewExt
u.DisableViewingExt = d.DisableViewingExt
u.DisableUpdateNotifications = d.DisableUpdateNotifications
u.DisableOfficePreviewExt = d.DisableOfficePreviewExt
u.DisableOnlyOfficeExt = d.DisableOnlyOfficeExt
u.CustomTheme = d.CustomTheme
u.ShowSelectMultiple = d.ShowSelectMultiple
u.DebugOffice = d.DebugOffice
u.DeleteWithoutConfirming = d.DeleteWithoutConfirming
u.DeleteAfterArchive = d.DeleteAfterArchive
u.PreferEditorForMarkdown = d.PreferEditorForMarkdown
u.FileLoading = d.FileLoading
// Handle Permissions - convert from pointer-based defaults to regular bools
u.Permissions.Api = Config.UserDefaults.Permissions.Api
u.Permissions.Admin = Config.UserDefaults.Permissions.Admin
u.Permissions.Modify = Config.UserDefaults.Permissions.Modify
u.Permissions.Share = Config.UserDefaults.Permissions.Share
u.Permissions.Realtime = Config.UserDefaults.Permissions.Realtime
u.Permissions.Delete = Config.UserDefaults.Permissions.Delete
u.Permissions.Create = Config.UserDefaults.Permissions.Create
u.Permissions.Download = boolValueOrDefault(Config.UserDefaults.Permissions.Download, true)
u.Preview.DisableHideSidebar = d.Preview.DisableHideSidebar
u.Preview.Image = boolValueOrDefault(d.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(d.Preview.Video, true)
u.Preview.Audio = boolValueOrDefault(d.Preview.Audio, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(d.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(d.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(d.Preview.PopUp, true)
u.Preview.AutoplayMedia = boolValueOrDefault(d.Preview.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = d.Preview.DefaultMediaPlayer
u.Preview.Folder = boolValueOrDefault(d.Preview.Folder, true)
u.Preview.Models = boolValueOrDefault(d.Preview.Models, true)
// Handle Preview - convert from pointer-based defaults to regular bools
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
u.Preview.HighQuality = boolValueOrDefault(Config.UserDefaults.Preview.HighQuality, true)
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
if u.Username == "anonymous" {
return
}
u.Permissions.Api = d.Permissions.Api
u.Permissions.Admin = d.Permissions.Admin
u.Permissions.Modify = d.Permissions.Modify
u.Permissions.Share = d.Permissions.Share
u.Permissions.Realtime = d.Permissions.Realtime
u.Permissions.Delete = d.Permissions.Delete
u.Permissions.Create = d.Permissions.Create
u.Permissions.Download = boolValueOrDefault(d.Permissions.Download, true)
if u.LoginMethod == "" && d.LoginMethod != "" {
u.LoginMethod = users.LoginMethod(d.LoginMethod)
}
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
u.ThemeColor = Config.UserDefaults.ThemeColor
u.GallerySize = Config.UserDefaults.GallerySize
u.QuickDownload = Config.UserDefaults.QuickDownload
u.LockPassword = Config.UserDefaults.LockPassword
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.FileLoading = Config.UserDefaults.FileLoading
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
if len(u.Scopes) == 0 {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
+34 -13
View File
@@ -58,10 +58,10 @@ type Server struct {
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
// not exposed to config
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
@@ -72,11 +72,22 @@ type Filesystem struct {
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
}
// Index SQL startup integrity modes (IndexSqlConfig.StartupIntegrityCheck).
const (
// IndexStartupIntegrityQuickCheck runs PRAGMA quick_check (default). Slower on very large DBs but thorough.
IndexStartupIntegrityQuickCheck = "quickCheck"
// IndexStartupIntegrityProbe verifies sqlite_master and optionally one row read only; fast for large indexes.
IndexStartupIntegrityProbe = "probe"
// IndexStartupIntegrityOff skips startup checks beyond sql.Open Ping; least safe, fastest boot.
IndexStartupIntegrityOff = "off"
)
type IndexSqlConfig struct {
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
StartupIntegrityCheck string `json:"startupIntegrityCheck,omitempty" validate:"omitempty,oneof=quickCheck probe off"` // the method used to check the integrity of the index database on startup (default: quickCheck)
}
type Integrations struct {
@@ -98,17 +109,19 @@ type Media struct {
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // extract embedded subtitles from media files
ExiftoolPath string `json:"exiftoolPath"` // path to exiftool executable
}
type FfmpegConvert struct {
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // supported image preview formats. defaults to false for all types unless explicitly enabled.
VideoPreview map[VideoPreviewType]bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
ImagePreview map[ImagePreviewType]*bool `json:"imagePreview"` // supported image preview formats. defaults vary by type (see individual type docs)
VideoPreview map[VideoPreviewType]*bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
}
type ImagePreviewType string
const (
HEICImagePreview ImagePreviewType = "heic"
JPEGImagePreview ImagePreviewType = "jpeg" // only used as fallback for JPEG formats that can't otherwise be decoded
//RAWImagePreview ImagePreviewType = "raw"
)
@@ -119,6 +132,7 @@ func (i ImagePreviewType) String() string {
// AllImagePreviewTypes contains all supported image preview types.
var AllImagePreviewTypes = []ImagePreviewType{
HEICImagePreview,
JPEGImagePreview,
//RAWImagePreview,
}
@@ -254,6 +268,7 @@ type ResolvedRulesConfig struct {
IgnoreAllZeroSizeFolders bool // Excludes all folders with 0 size
IgnoreAllSymlinks bool // Excludes all symbolic links
IndexingDisabled bool // Excludes all files and folders from indexing
NoRules bool // No rules are configured
}
type Frontend struct {
@@ -263,9 +278,11 @@ type Frontend struct {
ExternalLinks []ExternalLink `json:"externalLinks"`
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
Styling StylingConfig `json:"styling"`
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
LoginButtonText string `json:"loginButtonText"` // text to display on the login button
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
}
type StylingConfig struct {
@@ -294,15 +311,17 @@ type ExternalLink struct {
// UserDefaultsPreview holds preview settings with pointer types for defaults
type UserDefaultsPreview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
HighQuality *bool `json:"highQuality"` // use high quality thumbnails
HighQuality *bool `json:"highQuality"` // deprecated: always true in v1.3.0+
Image *bool `json:"image"` // show thumbnails for image files
Video *bool `json:"video"` // show thumbnails for video files
Audio *bool `json:"audio"` // show thumbnails for audio files
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office *bool `json:"office"` // show thumbnails for office files
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
Models *bool `json:"models"` // show live thumbnails for 3D models files
}
// UserDefaultsPermissions holds permission settings with pointer types for defaults
@@ -325,6 +344,7 @@ type UserDefaults struct {
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
@@ -344,11 +364,12 @@ type UserDefaults struct {
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order since 1.1.0
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer.
}
+11 -11
View File
@@ -26,7 +26,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -52,7 +52,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -81,16 +81,16 @@ integrations:
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// Check explicitly set formats
if !config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
if !*config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
t.Error("Expected mp4 to be enabled")
}
if config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
t.Error("Expected webm to be disabled")
}
if config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
t.Error("Expected mkv to be disabled")
}
if config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
if *config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
t.Error("Expected wmv to be disabled")
}
@@ -100,7 +100,7 @@ integrations:
M4VVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
}
for _, format := range unmentionedFormats {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled (not mentioned in config), but it was disabled", format)
}
}
@@ -155,13 +155,13 @@ integrations:
}
for _, format := range expectedEnabled {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled, but it was disabled", format)
}
}
for _, format := range expectedDisabled {
if config.Integrations.Media.Convert.VideoPreview[format] {
if *config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be disabled, but it was enabled", format)
}
}
@@ -182,7 +182,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -217,7 +217,7 @@ integrations:
for category, formats := range categories {
t.Run(category, func(t *testing.T) {
for _, format := range formats {
if !config.Integrations.Media.Convert.VideoPreview[format] {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s (%s) to be enabled by default, but it was disabled", format, category)
}
}
+46
View File
@@ -0,0 +1,46 @@
package utils
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"golang.org/x/crypto/bcrypt"
)
var BcryptCost = bcrypt.DefaultCost
var InvalidPasswordHash = ""
// HashPwd hashes a password.
func HashPwd(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
return string(bytes), err
}
// CheckPwd checks if a password is correct.
func CheckPwd(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
func SetInvalidPasswordHash() error {
passwordHash, err := HashPwd(InsecureRandomIdentifier(16))
if err != nil {
return err
}
InvalidPasswordHash = passwordHash
return nil
}
func HashSHA256(data string) string {
bytes := sha256.Sum256([]byte(data))
return hex.EncodeToString(bytes[:])
}
func GenerateKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return ""
}
return string(b)
}
+23 -10
View File
@@ -52,23 +52,36 @@ type FileOptions struct {
IsDir bool
Expand bool
Content bool
Recursive bool // whether to recursively index directories
Metadata bool // whether to get metadata
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
ShowHidden bool // whether to show hidden files (true = show, false = hide)
FollowSymlinks bool // whether to follow symlinks
Recursive bool // whether to recursively index directories
Metadata bool // whether to get metadata
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
ShowHidden bool // whether to show hidden files (true = show, false = hide)
FollowSymlinks bool // whether to follow symlinks
Only string // whether to only get files or folders
SkipExtendedAttrs bool // whether to skip extended attributes
ShowSharedAttr bool // whether to show share attributes
}
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
// Rule 1: Do Not Use User Input in File Paths (without validation)
func SanitizeUserPath(userPath string) (string, error) {
// Clean resolves . and .. segments
clean := filepath.Clean(userPath)
// Reject absolute paths or attempts to escape scope
if strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("invalid path")
// Split the path into segments to check for path traversal attempts
// We check if ".." appears as a complete path segment (not just in a filename)
segments := strings.Split(clean, string(filepath.Separator))
for _, segment := range segments {
// Check if any segment is exactly ".." (path traversal attempt)
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
if segment == ".." {
return "", fmt.Errorf("invalid path: path traversal detected")
}
}
if clean == "." {
return "", fmt.Errorf("invalid path: path must standard index path")
}
return clean, nil
-17
View File
@@ -2,9 +2,6 @@ package utils
import (
"cmp"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
math "math/rand"
"os"
@@ -23,15 +20,6 @@ func CheckErr(source string, err error) {
}
}
func GenerateKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return ""
}
return string(b)
}
// CapitalizeFirst returns the input string with the first letter capitalized.
func CapitalizeFirst(s string) string {
if len(s) == 0 {
@@ -92,11 +80,6 @@ func GetParentDirectoryPath(path string) string {
return path[:lastSlash]
}
func HashSHA256(data string) string {
bytes := sha256.Sum256([]byte(data))
return hex.EncodeToString(bytes[:])
}
func GetLastComponent(path string) string {
if path == "" {
return ""
+43
View File
@@ -0,0 +1,43 @@
package utils
import (
"errors"
"os"
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
}
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
// validate the size is not too large
info, err := os.Stat(subtitlePath)
if err != nil {
return "", err
}
if info.Size() > 1024*1024*50 { // 50MB
return "", errors.New("subtitle file is too large")
}
// First check if it's a text file using the consolidated validation logic
isText, err := IsTextFile(subtitlePath)
if err != nil {
return "", err
}
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
// read the file content
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
+42 -125
View File
@@ -1,148 +1,65 @@
package utils
import (
"io"
"os"
"unicode"
"unicode/utf8"
)
// IsTextFile checks if a file is likely a text file by examining its content.
// It returns true if the file appears to be a valid text file, false otherwise.
// This function performs various heuristics to detect text files:
// IsTextFile checks if a file is viewable/editable as text (like with cat or a text editor).
// It returns true if the file appears to be text, false if it's likely binary.
// This function uses simple heuristics:
// - Valid UTF-8 encoding
// - Limited null bytes
// - Limited non-printable characters
// - Not full of null bytes (which indicate binary data)
func IsTextFile(realPath string) (bool, 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 false, err
}
defer f.Close()
// Read header
headerBytes := make([]byte, headerSize)
n, err := f.Read(headerBytes)
if err != nil && err != io.EOF {
return false, err
}
actualHeader := headerBytes[:n]
// --- Start of 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 false, 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 false, 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 false, nil // Found problematic control character
}
}
}
// Now read the full file (original logic)
const sampleSize = 8192 // Read first 8KB to check
const maxNullByteRatio = 0.05 // Reject if more than 5% null bytes
// Read sample from file
content, err := os.ReadFile(realPath)
if err != nil {
return false, err
}
// Handle empty file (empty files are considered text)
// Empty files are considered text
if len(content) == 0 {
return true, nil
}
// Use sample for large files to avoid reading entire file into memory
sample := content
if len(content) > sampleSize {
sample = content[:sampleSize]
}
// Check 1: Count null bytes - binary files typically have many nulls
nullCount := 0
for _, b := range sample {
if b == 0x00 {
nullCount++
}
}
stringContent := string(content)
if nullCount > 0 {
nullRatio := float64(nullCount) / float64(len(sample))
if nullRatio > maxNullByteRatio {
return false, nil
}
}
// 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) {
// Check 2: Validate UTF-8 encoding
// Trim sample to last complete UTF-8 rune to avoid false negatives
trimmedSample := sample
for len(trimmedSample) > 0 {
lastRune, size := utf8.DecodeLastRune(trimmedSample)
if lastRune != utf8.RuneError {
break
}
if size == 1 {
trimmedSample = trimmedSample[:len(trimmedSample)-1]
} else {
break
}
}
if len(trimmedSample) > 0 && !utf8.Valid(trimmedSample) {
return false, 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 false, 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 false, nil // Too many non-printable runes
}
}
}
// The file has passed all checks and is considered editable text.
return true, nil
}
+1 -1
View File
@@ -8,4 +8,4 @@ userDefaults:
image: true
popup: true
video: true
highQuality: true
audio: true
+6 -2
View File
@@ -11,6 +11,7 @@ userDefaults:
disableQuickToggles: false # disable the quick toggles in the sidebar
disableSearchOptions: false # disable the search options in the search bar
stickySidebar: true # keep sidebar open when navigating
hideFilesInTree: false # hide files in the sidebar tree navigation, when true, will show only directories.
darkMode: true # should dark mode be enabled (defaults to true if not set)
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
singleClick: false # open directory on single click, also enables middle click to open in new tab
@@ -25,15 +26,16 @@ userDefaults:
disableSettings: false # disable the user from viewing the settings page
preview:
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
highQuality: true # generate high quality thumbnail preview images (defaults to true if not set)
image: true # show thumbnail preview image for image files (defaults to true if not set)
video: true # show thumbnail preview image for video files (defaults to true if not set)
audio: true # show thumbnail preview image for audio files (defaults to true if not set)
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
office: true # show thumbnail preview image for office files (defaults to true if not set)
popup: true # show larger popup preview when hovering over thumbnail (defaults to true if not set)
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
defaultMediaPlayer: false # disable html5 media player and use the default media player
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
permissions:
api: false # allow api access
admin: false # allow admin access
@@ -45,7 +47,9 @@ userDefaults:
download: true # allow downloading files (defaults to true if not set)
disableUpdateNotifications: false # disable update notifications banner for admin users
deleteWithoutConfirming: false # delete files without confirmation
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
customTheme: "" # Name of theme to use chosen from custom themes config.
showSelectMultiple: false # show select multiple files on desktop
debugOffice: false # debug onlyoffice editor
debugOffice: false # debug onlyoffice editor
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
+96 -13
View File
@@ -37,8 +37,10 @@ type SourceRuleMap map[string]RuleMap
type StringSet map[string]struct{}
type dbStorage struct {
AllRules SourceRuleMap `json:"all_rules"`
Groups GroupMap `json:"groups"`
AllRules SourceRuleMap `json:"all_rules"`
Groups GroupMap `json:"groups"`
RevokedTokens map[string]struct{} `json:"revoked_tokens"` // set of revoked token hashes
HashedTokens map[string]uint `json:"hashed_tokens"` // maps token hash → user ID
}
// RuleSet groups users and groups for allow/deny lists.
@@ -72,21 +74,26 @@ type GroupMap map[string]StringSet
// Storage manages access rules and group membership.
type Storage struct {
mux sync.RWMutex
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
DB *storm.DB // Optional: DB for persistence
Users *users.Storage // Reference to users storage
mux sync.RWMutex
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
RevokedTokens map[string]struct{} // set of revoked token hashes - in-memory authoritative state
HashedTokens map[string]uint // maps token hash → user ID - in-memory authoritative state
DB *storm.DB // Optional: DB for persistence
Users *users.Storage // Reference to users storage
}
// SaveToDB persists all rules to the DB if DB is set.
// IMPORTANT: Caller must hold s.mux lock (either read or write).
func (s *Storage) SaveToDB() error {
if s.DB == nil {
return nil
}
data, err := json.Marshal(&dbStorage{
AllRules: s.AllRules,
Groups: s.Groups,
AllRules: s.AllRules,
Groups: s.Groups,
RevokedTokens: s.RevokedTokens,
HashedTokens: s.HashedTokens,
})
if err != nil {
return err
@@ -97,6 +104,8 @@ func (s *Storage) SaveToDB() error {
// Flush persists the current in-memory state to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
s.mux.RLock()
defer s.mux.RUnlock()
return s.SaveToDB()
}
@@ -123,6 +132,18 @@ func (s *Storage) LoadFromDB() error {
if s.Groups == nil {
s.Groups = make(GroupMap)
}
s.RevokedTokens = storage.RevokedTokens
if s.RevokedTokens == nil {
s.RevokedTokens = make(map[string]struct{})
}
s.HashedTokens = storage.HashedTokens
if s.HashedTokens == nil {
s.HashedTokens = make(map[string]uint)
}
s.HashedTokens = storage.HashedTokens
if s.HashedTokens == nil {
s.HashedTokens = make(map[string]uint)
}
s.mux.Unlock()
return nil
}
@@ -136,10 +157,12 @@ func (s *Storage) LoadFromDB() error {
// if err != nil { /* handle error */ }
func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
var s = &Storage{
AllRules: make(SourceRuleMap),
Groups: make(GroupMap),
DB: db,
Users: usersStore,
AllRules: make(SourceRuleMap),
Groups: make(GroupMap),
RevokedTokens: make(map[string]struct{}),
HashedTokens: make(map[string]uint),
DB: db,
Users: usersStore,
}
return s
}
@@ -1236,3 +1259,63 @@ func (s *Storage) UpdateRulePath(sourcePath, oldPath, newPath string) error {
logger.Debugf("access rule path updated: source=%s, fromPath=%s, toPath=%s", sourcePath, oldPath, newPath)
return s.SaveToDB()
}
// RevokeToken adds a token hash to the revoked list and persists to DB.
func (s *Storage) RevokeToken(tokenHash string) error {
s.mux.Lock()
defer s.mux.Unlock()
tokenHash = utils.HashSHA256(tokenHash)
s.RevokedTokens[tokenHash] = struct{}{}
// Also remove from HashedTokens to prevent future lookups
delete(s.HashedTokens, tokenHash)
return s.SaveToDB()
}
// IsTokenRevoked checks if a token hash is in the revoked list (memory-only read).
func (s *Storage) IsTokenRevoked(tokenHash string) bool {
s.mux.RLock()
defer s.mux.RUnlock()
tokenHash = utils.HashSHA256(tokenHash)
_, exists := s.RevokedTokens[tokenHash]
return exists
}
// AddHashedToken adds a token hash to user ID mapping.
func (s *Storage) AddApiToken(tokenString string, userID uint) error {
s.mux.Lock()
defer s.mux.Unlock()
tokenHash := utils.HashSHA256(tokenString)
s.HashedTokens[tokenHash] = userID
return s.SaveToDB()
}
// GetUserIDFromToken retrieves the user ID for a given token string (memory-only read).
func (s *Storage) GetUserIDFromToken(tokenString string) (uint, bool) {
s.mux.RLock()
defer s.mux.RUnlock()
tokenHash := utils.HashSHA256(tokenString)
userID, exists := s.HashedTokens[tokenHash]
return userID, exists
}
// GetRevokedTokens returns a copy of all revoked token hashes.
func (s *Storage) GetRevokedTokens() map[string]struct{} {
s.mux.RLock()
defer s.mux.RUnlock()
// Return a copy to prevent external modification
result := make(map[string]struct{}, len(s.RevokedTokens))
for k := range s.RevokedTokens {
result[k] = struct{}{}
}
return result
}
// RemoveApiToken removes a token hash mapping (used when deleting API keys).
func (s *Storage) RemoveApiToken(tokenString string) error {
s.mux.Lock()
defer s.mux.Unlock()
tokenHash := utils.HashSHA256(tokenString)
delete(s.HashedTokens, tokenHash)
return s.SaveToDB()
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, index *indexing.Index, username string) error {
func (s *Storage) CheckChildItemAccess(response *iteminfo.FileInfo, index *indexing.Index, username string) error {
// Collect all item names to check
allItemNames := make([]string, 0, len(response.Folders)+len(response.Files))
@@ -1,4 +1,4 @@
package indexing
package dbindex
import (
"time"
@@ -17,6 +17,11 @@ type IndexInfo struct {
NumDirs uint64 `json:"numDirs"`
NumFiles uint64 `json:"numFiles"`
// Disk usage (cached from periodic GetIndexInfo / partition probes; aligns with ReducedIndex Stats)
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
// Scanner information - map of scanner path to scanner stats
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
}
@@ -1,4 +1,4 @@
package indexing
package dbindex
import (
"sync"
@@ -167,3 +167,37 @@ func (s *Storage) Flush() error {
}
return nil
}
// ResetAllComplexities sets the complexity to 0 for all indexes and their scanners.
// This should be called when the index database is recreated (either fresh start or after corruption).
func (s *Storage) ResetAllComplexities() error {
// Get all indexes from storage
infos, err := s.All()
if err != nil {
return err
}
// Reset complexity for each index
for _, info := range infos {
if info == nil {
continue
}
// Reset index-level complexity
info.Complexity = 0
// Reset scanner-level complexities
for _, scanner := range info.Scanners {
if scanner != nil {
scanner.Complexity = 0
}
}
// Save the updated info
if err := s.Save(info); err != nil {
return err
}
}
return nil
}
+69 -3
View File
@@ -2,10 +2,13 @@ package share
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// IsSingleFileShare determines if this share is for a single file (not a directory).
@@ -144,10 +147,73 @@ func (l *Link) HasReachedUserLimit(username string) bool {
return count >= l.DownloadsLimit
}
func (l *Link) GetSourceName() (string, error) {
func (l *Link) GetSourceName() string {
sourceInfo, ok := settings.Config.Server.SourceMap[l.Source]
if !ok {
return "", fmt.Errorf("source not found")
return ""
}
return sourceInfo.Name, nil
return sourceInfo.Name
}
func (l *Link) UserCanEdit(user *users.User) bool {
return l.UserID == user.ID || user.Permissions.Admin
}
func (l *Link) SourceURL(user *users.User) string {
sourceName := l.GetSourceName()
// get user scope path from share
userScope, err := user.GetScopeForSourceName(sourceName)
if err != nil {
return ""
}
if !strings.HasPrefix(l.Path, userScope) {
return ""
}
scopedPath := strings.TrimPrefix(l.Path, userScope)
return "/" + filepath.Join("files", sourceName, scopedPath)
}
func (l *Link) BannerURL() string {
_, _, err := l.GetShareImagePartsHelper(true)
if err == nil {
return fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", settings.Config.Server.ExternalUrl, settings.Config.Server.BaseURL, l.Hash)
}
return l.Banner
}
func (l *Link) FaviconURL() string {
_, _, err := l.GetShareImagePartsHelper(false)
if err == nil {
return fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", settings.Config.Server.ExternalUrl, settings.Config.Server.BaseURL, l.Hash)
}
return l.Favicon
}
func (l *Link) GetShareImagePartsHelper(isBanner bool) (string, string, error) {
// Get the asset query string from share
var assetQueryString string
if isBanner {
assetQueryString = l.Banner
} else {
assetQueryString = l.Favicon
}
if assetQueryString == "" {
return "", "", fmt.Errorf("asset not configured for this share")
}
// Parse the query string to extract source and path
assetParams, err := url.ParseQuery(assetQueryString)
if err != nil {
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
}
sourceName := assetParams.Get("source")
assetPath := assetParams.Get("path")
if sourceName == "" || assetPath == "" {
return "", "", fmt.Errorf("source or path not found in asset configuration")
}
return sourceName, assetPath, nil
}
+4 -2
View File
@@ -43,8 +43,10 @@ type CommonShare struct {
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HasPassword bool `json:"hasPassword,omitempty"`
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
DisableLoginOption bool `json:"disableLoginOption,omitempty"` // disable login option in share (true = hide, false = show)
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
SourceURL string `json:"sourceURL,omitempty"`
CanEditShare bool `json:"canEditShare,omitempty"`
}
type CreateBody struct {
CommonShare
+188 -142
View File
@@ -56,86 +56,179 @@ func (c *crudBackend) DeleteByID(id any) error {
// Storage is a share storage using generics.
type Storage struct {
Generic *crud.Storage[Link]
back StorageBackend
shareCache map[string]*Link
mu sync.RWMutex
users *users.Storage
Generic *crud.Storage[Link]
back StorageBackend
shareByHash map[string]*Link // key: link hash
shareByPath map[string]map[string]string // key: pathKey(Source, Path), value: set of hashes (hash -> "")
mu sync.RWMutex
users *users.Storage
}
// NewStorage creates a share links storage from a backend.
// pathKey returns the cache key for shareByPath (source + path).
func pathKey(source, path string) string {
return source + ":" + path
}
// setCacheLocked updates both shareByHash and shareByPath for the given link.
// Caller must hold s.mu.
func (s *Storage) setCacheLocked(link *Link) {
if link == nil {
return
}
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
s.shareByHash[link.Hash] = link
key := pathKey(adjustedSource, adjustedPath)
if s.shareByPath[key] == nil {
s.shareByPath[key] = make(map[string]string)
}
s.shareByPath[key][link.Hash] = ""
}
// setCache updates both caches for the given link. It acquires s.mu.
func (s *Storage) setCache(link *Link) {
s.mu.Lock()
s.setCacheLocked(link)
s.mu.Unlock()
}
// deleteFromCacheLocked removes the link (by hash) from both caches.
// Caller must hold s.mu.
func (s *Storage) deleteFromCacheLocked(hash string) {
link, ok := s.shareByHash[hash]
if !ok {
return
}
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
key := pathKey(adjustedSource, adjustedPath)
if inner, ok := s.shareByPath[key]; ok {
delete(inner, hash)
if len(inner) == 0 {
delete(s.shareByPath, key)
}
}
delete(s.shareByHash, hash)
}
// deleteFromCache removes the link (by hash) from both caches. It acquires s.mu.
func (s *Storage) deleteFromCache(hash string) {
s.mu.Lock()
s.deleteFromCacheLocked(hash)
s.mu.Unlock()
}
// setCacheAfterMove updates both caches when a link's Source or Path changed. It acquires s.mu.
func (s *Storage) setCacheAfterMove(link *Link, oldSource, oldPath string) {
s.mu.Lock()
if link == nil {
return
}
adjustedOldPath := utils.AddTrailingSlashIfNotExists(oldPath)
adjustedOldSource := utils.AddTrailingSlashIfNotExists(oldSource)
oldKey := pathKey(adjustedOldSource, adjustedOldPath)
if inner, ok := s.shareByPath[oldKey]; ok {
delete(inner, link.Hash)
if len(inner) == 0 {
delete(s.shareByPath, oldKey)
}
}
s.setCacheLocked(link)
s.mu.Unlock()
}
// NewStorage creates a share links storage from a backend and populates the
// in-memory cache from the database so all reads can be served from cache.
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
return &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareCache: make(map[string]*Link),
users: usersStore,
s := &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareByHash: make(map[string]*Link),
shareByPath: make(map[string]map[string]string),
users: usersStore,
}
s.loadShareCache()
return s
}
// All wraps StorageBackend.All and handles expiry.
func (s *Storage) All() ([]*Link, error) {
// loadShareCache fills shareByHash and shareByPath from the backend and removes expired links from the DB.
// Call once at startup so all reads can be served from cache.
func (s *Storage) loadShareCache() {
links, err := s.back.All()
if err != nil {
return nil, err
if err != nil && err != errors.ErrNotExist {
return
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
if links == nil {
links = []*Link{}
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
for _, link := range links {
if link == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
continue
}
link.InitUserDownloads()
s.setCacheLocked(link)
}
s.mu.Unlock()
return filtered, nil
}
// FindByUserID wraps StorageBackend.FindByUserID and handles expiry.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
links, err := s.back.FindByUserID(id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
// LoadShareCacheFromDB repopulates the in-memory cache from the database.
// Call at startup (e.g. from InitializeDb) to ensure shares are loaded after the store is ready.
func (s *Storage) LoadShareCacheFromDB() {
s.loadShareCache()
}
// All returns all non-expired shares from the cache (populated at startup and by writes).
func (s *Storage) All() ([]*Link, error) {
s.mu.Lock()
for i, l := range filtered {
result := make([]*Link, 0, len(s.shareByHash))
for _, l := range s.shareByHash {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return filtered, nil
return result, nil
}
// FindByUserID returns non-expired shares for the user from the cache.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
s.mu.Lock()
result := make([]*Link, 0)
for _, l := range s.shareByHash {
if l == nil || l.UserID != id {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
}
// GetByHash wraps StorageBackend.GetByHash and handles expiry.
func (s *Storage) GetByHash(hash string) (*Link, error) {
// return stable in-memory pointer if available
s.mu.RLock()
if link, ok := s.shareCache[hash]; ok && link != nil {
if link, ok := s.shareByHash[hash]; ok && link != nil {
s.mu.RUnlock()
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
s.deleteFromCache(hash)
return nil, errors.ErrNotExist
}
return link, nil
@@ -154,9 +247,7 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
// Initialize UserDownloads map
link.InitUserDownloads()
s.mu.Lock()
s.shareCache[hash] = link
s.mu.Unlock()
s.setCache(link)
return link, nil
}
@@ -164,68 +255,74 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
l, err := s.back.GetPermanent(path, source, id)
if err == nil && l != nil {
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
s.setCache(l)
}
return l, err
}
// Gets wraps StorageBackend.Gets and handles expiry.
// Gets returns shares for the given path, source, and user from the cache.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
adjustedPath := utils.AddTrailingSlashIfNotExists(sourcePath)
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
key := pathKey(adjustedSource, adjustedPath)
hashes := s.shareByPath[key]
result := make([]*Link, 0, len(hashes))
for h := range hashes {
l := s.shareByHash[h]
if l == nil || l.UserID != id {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return filtered, nil
return result, nil
}
// GetBySourcePath wraps StorageBackend.GetBySourcePath and handles expiry.
// GetBySourcePath returns shares for the given path and source from the cache.
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
links, err := s.back.GetBySourcePath(path, source)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
adjustedPath := utils.AddTrailingSlashIfNotExists(path)
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
key := pathKey(adjustedSource, adjustedPath)
hashes := s.shareByPath[key]
result := make([]*Link, 0, len(hashes))
for h := range hashes {
l := s.shareByHash[h]
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return filtered, nil
return result, nil
}
// IsShared returns whether the given path and source have any shares in the cache.
func (s *Storage) IsShared(path, source string, id uint) bool {
links, _ := s.GetBySourcePath(path, source)
for _, l := range links {
if l.UserID == id {
return true
}
}
return len(links) > 0
}
// UpdateShares updates all shares that match oldSource and oldPath to point to newSource and newPath.
// Handles both exact matches and subdirectories, regardless of trailing slashes.
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
links, err := s.back.All()
links, err := s.All()
if err != nil && err != errors.ErrNotExist {
logger.Error("failed to list shares", "error", err)
return 0, err
@@ -247,7 +344,6 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = newPath
@@ -256,22 +352,9 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
return updated, err
}
s.mu.Lock()
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
existing.Source = l.Source
existing.Path = l.Path
} else {
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
s.setCacheAfterMove(l, oldSource, oldPath)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
@@ -290,11 +373,7 @@ func (s *Storage) UpdateSharePath(hash, newPath string) error {
return err
}
s.mu.Lock()
if existing, ok := s.shareCache[hash]; ok && existing != nil {
existing.Path = newPath
}
s.mu.Unlock()
s.setCacheAfterMove(link, link.Source, oldPath)
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
return nil
@@ -305,9 +384,7 @@ func (s *Storage) Save(l *Link) error {
if err := s.back.Save(l); err != nil {
return err
}
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
s.setCache(l)
return nil
}
@@ -316,47 +393,16 @@ func (s *Storage) Delete(hash string) error {
if err := s.back.Delete(hash); err != nil {
return err
}
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
s.deleteFromCache(hash)
return nil
}
// Flush persists the current in-memory state of all shares to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
// Flush is a no-op: every share write (Save, Delete, UpdateShares, UpdateSharePath) already
// updates the database, so the cache and DB stay in sync without flushing.
func (s *Storage) Flush() error {
s.mu.RLock()
links := make([]*Link, 0, len(s.shareCache))
for _, l := range s.shareCache {
if l != nil {
links = append(links, l)
}
}
s.mu.RUnlock()
for _, l := range links {
if err := s.back.Save(l); err != nil {
return err
}
}
return nil
}
// filterExpired removes expired links and deletes them from storage.
func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
var filtered []*Link
for _, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
s.mu.Lock()
delete(s.shareCache, link.Hash)
s.mu.Unlock()
continue
}
filtered = append(filtered, link)
}
return filtered, nil
}
// GetCommonShareByHash retrieves only the CommonShare attributes for a given hash
func (s *Storage) GetCommonShareByHash(hash string) (*CommonShare, error) {
return s.back.GetCommonShareByHash(hash)
+183 -108
View File
@@ -2,11 +2,14 @@ package sql
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -41,7 +44,7 @@ func createIndexDB(name string, journalMode string, lockingMode string, batchSiz
BatchSize: batchSize,
CacheSizeKB: cacheSizeKB, // From config, converted to KB
SoftHeapLimitBytes: 16 * 1024 * 1024, // 16MB soft heap limit (reduced to minimize memory pressure)
CacheSpillThreshold: 200, // Spill dirty pages to disk when cache exceeds 200 pages (~800KB) - more aggressive
CacheSpillThreshold: 2000, // Spill dirty pages to disk when cache exceeds 2000 pages (~8MB)
MmapSize: 0, // Disable mmap to prevent additional OS page cache usage
Synchronous: "OFF", // No sync for maximum performance - safe since DB can be rebuilt
TempStore: "FILE", // FILE instead of MEMORY to reduce memory usage
@@ -61,39 +64,123 @@ func createIndexDB(name string, journalMode string, lockingMode string, batchSiz
// batchSize: number of items per batch transaction
// cacheSizeMB: cache size in megabytes
// disableReuse: true to delete and recreate DB on startup, false to reuse existing (default)
func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int, disableReuse bool) (*IndexDB, error) {
// Returns the IndexDB and a boolean indicating if the database is new (true) or reused (false)
func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int, disableReuse bool) (*IndexDB, bool, error) {
isNewDb := disableReuse // If disableReuse is true, we're creating fresh
// Check if database file exists (only if reuse is enabled)
if !disableReuse {
// Database files are stored in sql subdirectory (see sqlite.go:207)
dbPath := filepath.Join(settings.Config.Server.CacheDir, "sql", fmt.Sprintf("index_%s.db", name))
_, err := os.Stat(dbPath)
if os.IsNotExist(err) {
// Database file doesn't exist, so this is a new database
isNewDb = true
} else if err != nil {
// Some other error occurred
logger.Warningf("Error checking database file: %v", err)
isNewDb = true
}
}
idxDB, err := createIndexDB(name, journalMode, "", batchSize, cacheSizeMB, disableReuse)
if err != nil {
return nil, err
return nil, false, err
}
// For persistent databases, check integrity on startup
// If corrupted, delete and recreate the database
if !disableReuse {
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
// Database was recreated, need to create a new connection
mode := normalizeStartupIntegrityMode(settings.Config.Server.IndexSqlConfig.StartupIntegrityCheck)
if err := idxDB.runStartupIntegrityCheck(mode); err != nil {
// Database was recreated due to corruption
logger.Warningf("[DB_INIT] SQLite database was found with issues which could be due to a dirty shutdown, recreating: tmp/index_%s.db", name)
isNewDb = true
idxDB.Close()
// Recreate the database connection
idxDB, err = createIndexDB(name, journalMode, "", batchSize, cacheSizeMB, disableReuse)
if err != nil {
return nil, err
return nil, false, err
}
}
}
if err := idxDB.CreateIndexTable(); err != nil {
idxDB.Close()
return nil, err
return nil, false, err
}
go idxDB.startPeriodicCleanup()
return idxDB, nil
return idxDB, isNewDb, nil
}
func normalizeStartupIntegrityMode(m string) string {
switch m {
case "", settings.IndexStartupIntegrityQuickCheck:
return settings.IndexStartupIntegrityQuickCheck
case settings.IndexStartupIntegrityProbe:
return settings.IndexStartupIntegrityProbe
case settings.IndexStartupIntegrityOff:
return settings.IndexStartupIntegrityOff
default:
logger.Warningf("[DB_INIT] unknown indexSqlConfig.startupIntegrityCheck %q, using %s", m, settings.IndexStartupIntegrityQuickCheck)
return settings.IndexStartupIntegrityQuickCheck
}
}
// runStartupIntegrityCheck runs the configured startup check (quick_check, probe, or off).
func (db *IndexDB) runStartupIntegrityCheck(mode string) error {
switch mode {
case settings.IndexStartupIntegrityOff:
logger.Debugf("[DB_INTEGRITY] startup integrity skipped (startupIntegrityCheck=off)")
return nil
case settings.IndexStartupIntegrityProbe:
return db.checkAccessibilityProbe()
default:
// quickCheck (including normalized default)
return db.checkIntegrityAndRecreateIfNeeded()
}
}
// checkAccessibilityProbe verifies the DB file is readable: sqlite_master and optionally one row from index_items.
// If index_items is absent (brand-new file), succeeds so CreateIndexTable can create it.
func (db *IndexDB) checkAccessibilityProbe() error {
start := time.Now()
defer func() {
logger.Debugf("[DB_INTEGRITY] startup probe completed in %v", time.Since(start))
}()
var n int
err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'index_items'`).Scan(&n)
if err != nil {
logger.Warningf("[DB_INTEGRITY] probe: cannot read sqlite_master: %v", err)
return db.deleteCorruptedDatabase()
}
if n == 0 {
logger.Debugf("[DB_INTEGRITY] probe: index_items not present yet (new database), skipping row read")
return nil
}
var dummy int
err = db.QueryRow(`SELECT 1 FROM index_items LIMIT 1`).Scan(&dummy)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
logger.Warningf("[DB_INTEGRITY] probe: cannot read index_items: %v", err)
return db.deleteCorruptedDatabase()
}
return nil
}
// checkIntegrityAndRecreateIfNeeded checks database integrity.
// If corruption is detected, deletes the database file and returns an error to trigger recreation.
// Returns nil if integrity is OK, error if database needs to be recreated.
func (db *IndexDB) checkIntegrityAndRecreateIfNeeded() error {
start := time.Now()
defer func() {
logger.Debugf("[DB_INTEGRITY] PRAGMA quick_check completed in %v", time.Since(start))
}()
// Quick integrity check
var result string
err := db.QueryRow("PRAGMA quick_check").Scan(&result)
@@ -211,28 +298,70 @@ func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) erro
return err
}
// dbTransientOpAttempts is how many times to run a bulk DB op that may hit SQLITE_BUSY before giving up.
const dbTransientOpAttempts = 2
const dbTransientRetryBackoff = 50 * time.Millisecond
// BulkInsertItems inserts multiple items in a single transaction for a specific source.
// Database errors (busy/locked) are treated as soft failures - the filesystem is the source of truth.
// Returns nil on success or soft failure (busy/locked), error only on hard failures.
// Transient busy/locked errors retry once before giving up (see dbTransientOpAttempts).
func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) error {
if len(items) == 0 {
return nil
}
startTime := time.Now()
for attempt := 1; attempt <= dbTransientOpAttempts; attempt++ {
ok, transient, err := db.attemptBulkInsert(source, items)
if err != nil {
return err
}
if ok {
if attempt > 1 {
logger.Infof("[DB_TX] BulkInsertItems: succeeded on retry (%d items, attempt %d, %v)",
len(items), attempt, time.Since(startTime))
}
return nil
}
if transient && attempt < dbTransientOpAttempts {
logger.Warningf("[DB_TX] BulkInsertItems: transient DB error (attempt %d/%d, %d items), retrying after %v",
attempt, dbTransientOpAttempts, len(items), dbTransientRetryBackoff)
time.Sleep(dbTransientRetryBackoff)
continue
}
if transient {
logger.Warningf("[DB_TX] BulkInsertItems: still failing after %d attempts (%d items, %v) — batch dropped",
dbTransientOpAttempts, len(items), time.Since(startTime))
}
return nil
}
return nil // defensive if dbTransientOpAttempts is ever 0 and loop never runs
}
// attemptBulkInsert runs one transactional bulk insert.
// Returns success=true when committed; transient=true when SQLITE_BUSY-like errors allow a retry (mutex released).
func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo) (success bool, transient bool, err error) {
tx, err := db.BeginTransaction()
if err != nil {
// Soft failure: DB is busy or locked, skip this update
if isBusyError(err) || isTransactionError(err) {
db.mu.Unlock() // Release mutex on error
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked), skipping - took %v", time.Since(startTime))
return nil // Non-fatal: filesystem will be used as fallback
db.mu.Unlock()
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked): %v", err)
return false, true, nil
}
db.mu.Unlock() // Release mutex on error
return err // Hard failure: something is wrong with the DB
db.mu.Unlock()
return false, false, err
}
defer db.mu.Unlock() // Ensure mutex is always released
// Always update all fields including last_updated, even if nothing changed
// This ensures last_updated stays current for stale entry detection
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
db.mu.Unlock()
}()
stmt, err := tx.Prepare(`
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -249,14 +378,12 @@ func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) er
`)
if err != nil {
if isBusyError(err) || isTransactionError(err) {
// With EXCLUSIVE locking and mutex, this shouldn't happen.
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Prepare: %v", err)
return nil
logger.Debugf("[DB_TX] BulkInsertItems: Prepare busy/locked: %v", err)
return false, true, nil
}
return err
return false, false, err
}
defer stmt.Close() // Ensure statement is always closed
defer stmt.Close()
nowUnix := time.Now().Unix()
for _, info := range items {
@@ -276,28 +403,23 @@ func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) er
)
if err != nil {
if isBusyError(err) || isTransactionError(err) {
// With EXCLUSIVE locking and mutex, this shouldn't happen.
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Exec: %v", err)
return nil
logger.Debugf("[DB_TX] BulkInsertItems: Exec busy/locked during insert: %v", err)
return false, true, nil
}
logger.Errorf("[DB] BulkInsertItems: Exec failed for path=%s, source=%s, error=%v", info.Path, source, err)
return err
return false, false, err
}
}
// Try to commit
if err := tx.Commit(); err != nil {
if isBusyError(err) || isTransactionError(err) {
// With EXCLUSIVE locking and mutex, this shouldn't happen.
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Commit: %v", err)
return nil
logger.Debugf("[DB_TX] BulkInsertItems: Commit busy/locked: %v", err)
return false, true, nil
}
return err
return false, false, err
}
return nil
committed = true
return true, false, nil
}
// isBusyError checks if an error is SQLITE_BUSY (error code 5)
@@ -387,51 +509,6 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
return result, nil
}
// GetHasPreviewBatch retrieves hasPreview status for multiple directory paths in a single query.
// This is optimized for the N+1 query pattern in GetDirInfo where we need hasPreview for all subdirectories.
// Returns map[path]hasPreview. Missing paths in the result indicate they're not in the database yet.
func (db *IndexDB) GetHasPreviewBatch(source string, paths []string) (map[string]bool, error) {
if len(paths) == 0 {
return make(map[string]bool), nil
}
// Build query with IN clause - only select path and has_preview for efficiency
placeholders := make([]string, len(paths))
args := make([]interface{}, len(paths)+1)
args[0] = source
for i, path := range paths {
placeholders[i] = "?"
args[i+1] = path
}
query := fmt.Sprintf(`
SELECT path, has_preview
FROM index_items WHERE source = ? AND path IN (%s) AND is_dir = 1
`, strings.Join(placeholders, ","))
rows, err := db.Query(query, args...)
if err != nil {
// Soft failure: DB is busy or locked, return empty map
if isBusyError(err) || isTransactionError(err) {
return make(map[string]bool), nil
}
return nil, err
}
defer rows.Close()
result := make(map[string]bool)
for rows.Next() {
var path string
var hasPreview bool
if err := rows.Scan(&path, &hasPreview); err != nil {
return nil, err
}
result[path] = hasPreview
}
return result, nil
}
// GetDirectoryChildren retrieves all children of a directory for a specific source.
func (db *IndexDB) GetDirectoryChildren(source, dirPath string) ([]*iteminfo.FileInfo, error) {
query := `
@@ -519,7 +596,7 @@ func (db *IndexDB) ExplainQueryPlan(query string, args ...interface{}) (string,
if err := rows.Scan(&id, &parent, &notused, &detail); err != nil {
return "", err
}
plan.WriteString(fmt.Sprintf("%d|%d|%s\n", id, parent, detail))
fmt.Fprintf(&plan, "%d|%d|%s\n", id, parent, detail)
}
return plan.String(), rows.Err()
}
@@ -990,17 +1067,31 @@ func (db *IndexDB) UpdateFolderSizesIfChanged(source string, pathSizes map[strin
args = append(args, path)
}
result, err := db.Exec(query, args...)
if err != nil {
if isBusyError(err) || isTransactionError(err) {
logger.Debugf("[DB_TX] UpdateFolderSizesIfChanged: DB busy/locked, skipping update")
return 0, nil // Non-fatal: sizes will be synced on next scan
startTime := time.Now()
for attempt := 1; attempt <= dbTransientOpAttempts; attempt++ {
result, err := db.Exec(query, args...)
if err != nil {
if isBusyError(err) || isTransactionError(err) {
if attempt < dbTransientOpAttempts {
logger.Warningf("[DB_TX] UpdateFolderSizesIfChanged: busy/locked (attempt %d/%d), retrying after %v: %v",
attempt, dbTransientOpAttempts, dbTransientRetryBackoff, err)
time.Sleep(dbTransientRetryBackoff)
continue
}
logger.Warningf("[DB_TX] UpdateFolderSizesIfChanged: still busy after %d attempts (%d paths, %v) — skipped",
dbTransientOpAttempts, len(pathSizes), time.Since(startTime))
return 0, nil
}
return 0, err
}
return 0, err
n, _ := result.RowsAffected()
if attempt > 1 {
logger.Infof("[DB_TX] UpdateFolderSizesIfChanged: succeeded on retry (%d rows, attempt %d, %v)",
n, attempt, time.Since(startTime))
}
return n, nil
}
rowsAffected, _ := result.RowsAffected()
return rowsAffected, nil
return 0, nil // defensive: unreachable when dbTransientOpAttempts >= 1
}
// LoadFolderSizes loads all folder sizes for a source from the database
@@ -1176,23 +1267,6 @@ func (db *IndexDB) GetSizeGroupsForDuplicates(source string, minSize int64, path
return sizes, sizeCounts, rows.Err()
}
// GetTotalSize returns the sum of all file sizes in the index for a specific source (excluding directories).
func (db *IndexDB) GetTotalSize(source string) (uint64, error) {
query := `SELECT COALESCE(SUM(size), 0) FROM index_items WHERE source = ? AND is_dir = 0`
var totalSize int64
err := db.QueryRow(query, source).Scan(&totalSize)
if err != nil {
// Soft failure: DB is busy or locked, return 0
if isBusyError(err) || isTransactionError(err) {
return 0, nil
}
return 0, err
}
return uint64(totalSize), nil
}
// Helper functions
// getNextPathPrefix returns the lexicographically next string after the given prefix.
@@ -1250,6 +1324,7 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
return nil, err
}
info.ModTime = time.Unix(modTime, 0)
info.IsDir = isDir
return &info, nil
}
+129
View File
@@ -0,0 +1,129 @@
package sql
import (
"os"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func TestMain(m *testing.M) {
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0o644, 0o755)
}
os.Exit(m.Run())
}
func TestNormalizeStartupIntegrityMode(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", settings.IndexStartupIntegrityQuickCheck},
{"quickCheck", settings.IndexStartupIntegrityQuickCheck},
{"probe", settings.IndexStartupIntegrityProbe},
{"off", settings.IndexStartupIntegrityOff},
{"not-a-real-mode", settings.IndexStartupIntegrityQuickCheck},
}
for _, tt := range tests {
if got := normalizeStartupIntegrityMode(tt.in); got != tt.want {
t.Errorf("normalizeStartupIntegrityMode(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
// pushTestIndexConfig sets CacheDir and index SQL options for NewIndexDB; returned func restores prior values.
func pushTestIndexConfig(t *testing.T, cacheDir string, idx settings.IndexSqlConfig) func() {
t.Helper()
prevDir := settings.Config.Server.CacheDir
prevIdx := settings.Config.Server.IndexSqlConfig
settings.Config.Server.CacheDir = cacheDir
settings.Config.Server.IndexSqlConfig = idx
return func() {
settings.Config.Server.CacheDir = prevDir
settings.Config.Server.IndexSqlConfig = prevIdx
}
}
func testIndexSQLConfig(mode string) settings.IndexSqlConfig {
return settings.IndexSqlConfig{
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
StartupIntegrityCheck: mode,
}
}
func TestNewIndexDB_StartupProbe_NewFile(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
defer pop()
db, _, err := NewIndexDB("probe_new", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
func TestNewIndexDB_StartupProbe_ReopenWithData(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
defer pop()
db, _, err := NewIndexDB("probe_reopen", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
info := &iteminfo.FileInfo{
Path: "/a.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "a.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
Hidden: false,
},
}
if err = db.InsertItem("src1", "/a.txt", info); err != nil {
t.Fatal(err)
}
if err = db.Close(); err != nil {
t.Fatal(err)
}
var db2 *IndexDB
db2, _, err = NewIndexDB("probe_reopen", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db2.Close()
}
func TestNewIndexDB_StartupQuickCheckStillWorks(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityQuickCheck))
defer pop()
db, _, err := NewIndexDB("quick_default", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
func TestNewIndexDB_StartupOff(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
defer pop()
db, _, err := NewIndexDB("off_mode", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
+94 -24
View File
@@ -247,12 +247,22 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
db.SetMaxIdleConns(1)
}
// Calculate cache_size in pages (negative value) to ensure consistent unit handling
// SQLite's cache_size: positive = KB, negative = pages
// When using positive KB values, SQLite seems to have issues with cache_spill being less than cache_size
// So we convert to pages (negative) to ensure both cache_size and cache_spill use the same units
cacheSizeInPages := cfg.CacheSizeKB
if cfg.CacheSizeKB > 0 {
// Convert KB to pages: KB / 4 = pages, then negate to indicate pages
cacheSizeInPages = -(cfg.CacheSizeKB / 4)
}
basePragmas := []struct {
sql string
err string
}{
{fmt.Sprintf("PRAGMA journal_mode = %s;", cfg.JournalMode), "failed to set journal_mode"},
{fmt.Sprintf("PRAGMA cache_size = %d;", cfg.CacheSizeKB), "failed to set cache_size"},
{fmt.Sprintf("PRAGMA cache_size = %d;", cacheSizeInPages), "failed to set cache_size"},
{fmt.Sprintf("PRAGMA synchronous = %s;", cfg.Synchronous), "failed to set synchronous"},
{fmt.Sprintf("PRAGMA temp_store = %s;", cfg.TempStore), "failed to set temp_store"},
{fmt.Sprintf("PRAGMA locking_mode = %s;", cfg.LockingMode), "failed to set locking_mode"},
@@ -274,19 +284,7 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
}{{fmt.Sprintf("PRAGMA hard_heap_limit = %d;", cfg.HardHeapLimitBytes), "failed to set hard_heap_limit"}}, basePragmas...)
}
// Cache spill threshold - triggers early spilling to reduce memory usage
if cfg.CacheSpillThreshold > 0 {
basePragmas = append(basePragmas, struct {
sql string
err string
}{fmt.Sprintf("PRAGMA cache_spill = %d;", cfg.CacheSpillThreshold), "failed to set cache_spill"})
} else {
// Enable cache_spill by default (value of 1 enables it)
basePragmas = append(basePragmas, struct {
sql string
err string
}{"PRAGMA cache_spill = 1;", "failed to set cache_spill"})
}
// Cache spill will be set later, after all other pragmas, to avoid interaction issues
// Page size must be set before any tables are created
if cfg.PageSize > 0 {
@@ -333,6 +331,32 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
}
}
// Set cache_spill LAST, after all other pragmas are configured
// SQLite may adjust cache_spill based on internal constraints or formulas
if cfg.CacheSpillThreshold > 0 {
// Try setting cache_spill multiple times with different approaches
// Approach 1: Standard syntax
if _, err := db.Exec(fmt.Sprintf("PRAGMA cache_spill = %d;", cfg.CacheSpillThreshold)); err != nil {
logger.Warningf("[SQLITE_CONFIG] Failed to set cache_spill with standard syntax: %v", err)
}
// Immediately verify what value was actually set
var actualValue int64
if err := db.QueryRow("PRAGMA cache_spill").Scan(&actualValue); err == nil {
if actualValue != int64(cfg.CacheSpillThreshold) {
// SQLite adjusted our value - calculate what percentage it used
percentage := float64(actualValue) / float64(-cacheSizeInPages) * 100.0
logger.Warningf("[SQLITE_CONFIG] SQLite adjusted cache_spill from %d to %d pages (%.1f%% of cache_size). This may be due to internal constraints.",
cfg.CacheSpillThreshold, actualValue, percentage)
}
}
} else {
// Enable cache_spill with default behavior
if _, err := db.Exec("PRAGMA cache_spill = 1;"); err != nil {
logger.Warningf("[SQLITE_CONFIG] Failed to enable cache_spill: %v", err)
}
}
// Verify PRAGMA settings were applied correctly
if err := verifyPragmaSettings(db, cfg); err != nil {
logger.Warningf("Failed to verify PRAGMA settings: %v", err)
@@ -452,18 +476,39 @@ func verifyPragmaSettings(db *sql.DB, cfg *TempDBConfig) error {
cacheSizeKB = -cacheSize * 4 // pages * 4KB per page
}
// Expected cache_spill value in pages (cache_spill is always specified and returned in pages)
expectedCacheSpill := int64(cfg.CacheSpillThreshold)
// Convert numeric PRAGMA values to human-readable strings
tempStoreMap := map[string]string{"0": "DEFAULT", "1": "FILE", "2": "MEMORY"}
synchronousMap := map[string]string{"0": "OFF", "1": "NORMAL", "2": "FULL", "3": "EXTRA"}
autoVacuumMap := map[string]string{"0": "NONE", "1": "FULL", "2": "INCREMENTAL"}
tempStoreStr := tempStoreMap[tempStore]
if tempStoreStr == "" {
tempStoreStr = tempStore
}
synchronousStr := synchronousMap[synchronous]
if synchronousStr == "" {
synchronousStr = synchronous
}
autoVacuumStr := autoVacuumMap[autoVacuum]
if autoVacuumStr == "" {
autoVacuumStr = autoVacuum
}
// Log all settings
logger.Debugf("[SQLITE_CONFIG] Database PRAGMA settings verified:")
logger.Debugf(" cache_size: %d KB (configured: %d KB)", cacheSizeKB, cfg.CacheSizeKB)
logger.Debugf(" mmap_size: %d bytes (configured: %d bytes)", mmapSize, cfg.MmapSize)
logger.Debugf(" soft_heap_limit: %d bytes (configured: %d bytes)", softHeapLimit, cfg.SoftHeapLimitBytes)
logger.Debugf(" hard_heap_limit: %d bytes (configured: %d bytes)", hardHeapLimit, cfg.HardHeapLimitBytes)
logger.Debugf(" cache_spill: %d pages (configured: %d pages)", cacheSpill, cfg.CacheSpillThreshold)
logger.Debugf(" journal_mode: %s (configured: %s)", journalMode, cfg.JournalMode)
logger.Debugf(" synchronous: %s (configured: %s)", synchronous, cfg.Synchronous)
logger.Debugf(" temp_store: %s (configured: %s)", tempStore, cfg.TempStore)
logger.Debugf(" locking_mode: %s (configured: %s)", lockingMode, cfg.LockingMode)
logger.Debugf(" auto_vacuum: %s (configured: %s)", autoVacuum, cfg.AutoVacuum)
logger.Debugf(" cache_size : %d KB", cacheSizeKB)
logger.Debugf(" mmap_size : %d bytes", mmapSize)
logger.Debugf(" soft_heap_limit : %d bytes", softHeapLimit)
logger.Debugf(" hard_heap_limit : %d bytes", hardHeapLimit)
logger.Debugf(" cache_spill : %d pages", cacheSpill)
logger.Debugf(" journal_mode : %s", journalMode)
logger.Debugf(" synchronous : %s", synchronousStr)
logger.Debugf(" temp_store : %s", tempStoreStr)
logger.Debugf(" locking_mode : %s", lockingMode)
logger.Debugf(" auto_vacuum : %s", autoVacuumStr)
// Verify critical settings match
if cfg.CacheSizeKB != 0 {
@@ -484,6 +529,31 @@ func verifyPragmaSettings(db *sql.DB, cfg *TempDBConfig) error {
if cfg.HardHeapLimitBytes > 0 && hardHeapLimit != cfg.HardHeapLimitBytes {
logger.Warningf("[SQLITE_CONFIG] hard_heap_limit mismatch: got %d bytes, expected %d bytes", hardHeapLimit, cfg.HardHeapLimitBytes)
}
// Verify cache_spill matches expected value (accounting for unit conversion)
if cfg.CacheSpillThreshold > 0 && cacheSpill != expectedCacheSpill {
logger.Warningf("[SQLITE_CONFIG] cache_spill mismatch: got %d pages, expected %d pages (from threshold %d pages)", cacheSpill, expectedCacheSpill, cfg.CacheSpillThreshold)
// If cache_spill equals cache_size in pages, it means SQLite ignored our setting
var cacheSizePages int64
if cfg.CacheSizeKB > 0 {
cacheSizePages = int64(cfg.CacheSizeKB) / 4 // KB to pages
} else {
cacheSizePages = -int64(cfg.CacheSizeKB) // Already in pages (negative)
}
if cacheSpill == cacheSizePages {
logger.Warningf("[SQLITE_CONFIG] cache_spill equals cache_size (%d pages), indicating SQLite may have ignored our setting", cacheSizePages)
}
}
// Verify string-based PRAGMA settings match (using converted values)
if synchronousStr != cfg.Synchronous {
logger.Warningf("[SQLITE_CONFIG] synchronous mismatch: got %s, expected %s", synchronousStr, cfg.Synchronous)
}
if tempStoreStr != cfg.TempStore {
logger.Warningf("[SQLITE_CONFIG] temp_store mismatch: got %s, expected %s", tempStoreStr, cfg.TempStore)
}
if autoVacuumStr != cfg.AutoVacuum {
logger.Warningf("[SQLITE_CONFIG] auto_vacuum mismatch: got %s, expected %s", autoVacuumStr, cfg.AutoVacuum)
}
return nil
}
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/indexing"
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
@@ -19,7 +19,7 @@ type BoltStore struct {
Auth *auth.Storage
Settings *settings.Storage
Access *access.Storage
Indexing *indexing.Storage
Indexing *dbindex.Storage
}
// NewStorage creates a storage.Storage based on Bolt DB.
@@ -35,6 +35,6 @@ func NewStorage(db *storm.DB) (*BoltStore, error) {
Auth: authStore,
Settings: settings.NewStorage(settingsBackend{db: db}),
Access: access.NewStorage(db, userStore),
Indexing: indexing.NewStorage(indexingBackend{db: db}),
Indexing: dbindex.NewStorage(indexingBackend{db: db}),
}, nil
}
+9 -9
View File
@@ -5,15 +5,15 @@ import (
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/indexing"
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
)
type indexingBackend struct {
db *storm.DB
}
func (s indexingBackend) All() ([]*indexing.IndexInfo, error) {
var v []*indexing.IndexInfo
func (s indexingBackend) All() ([]*dbindex.IndexInfo, error) {
var v []*dbindex.IndexInfo
err := s.db.All(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
@@ -22,8 +22,8 @@ func (s indexingBackend) All() ([]*indexing.IndexInfo, error) {
return v, err
}
func (s indexingBackend) GetByPath(path string) (*indexing.IndexInfo, error) {
var v indexing.IndexInfo
func (s indexingBackend) GetByPath(path string) (*dbindex.IndexInfo, error) {
var v dbindex.IndexInfo
err := s.db.One("Path", path, &v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
@@ -32,8 +32,8 @@ func (s indexingBackend) GetByPath(path string) (*indexing.IndexInfo, error) {
return &v, err
}
func (s indexingBackend) GetBySource(source string) ([]*indexing.IndexInfo, error) {
var v []*indexing.IndexInfo
func (s indexingBackend) GetBySource(source string) ([]*dbindex.IndexInfo, error) {
var v []*dbindex.IndexInfo
err := s.db.Select(q.Eq("Source", source)).Find(&v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
@@ -41,12 +41,12 @@ func (s indexingBackend) GetBySource(source string) ([]*indexing.IndexInfo, erro
return v, err
}
func (s indexingBackend) Save(info *indexing.IndexInfo) error {
func (s indexingBackend) Save(info *dbindex.IndexInfo) error {
return s.db.Save(info)
}
func (s indexingBackend) Delete(path string) error {
err := s.db.DeleteStruct(&indexing.IndexInfo{Path: path})
err := s.db.DeleteStruct(&dbindex.IndexInfo{Path: path})
if err == storm.ErrNotFound {
return nil
}
+24 -20
View File
@@ -9,7 +9,6 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
@@ -75,11 +74,6 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return err
}
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !user.OtpEnabled {
return errors.ErrNoTotpConfigured
}
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
@@ -87,11 +81,21 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
if err != nil {
return err
}
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
if user.TOTPNonce == "" {
user.TOTPNonce = existingUser.TOTPNonce
}
if user.TOTPSecret == "" {
user.TOTPSecret = existingUser.TOTPSecret
}
if user.LoginMethod == users.LoginMethodPassword && enforcedOtp && !user.OtpEnabled {
if slices.Contains(fields, "OtpEnabled") {
return errors.ErrNoTotpConfigured
}
}
if !slices.Contains(fields, "Password") {
user.Password = existingUser.Password
} else {
@@ -114,10 +118,16 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
if err != nil {
return err
}
for _, scope := range adjustedScopes {
// ensure full path exists
if scope.Scope == "" {
return fmt.Errorf("scope name is required")
}
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, true)
err = files.MakeUserDirs(user, false)
if err != nil {
logger.Error(err.Error())
return err
}
}
// converting scopes to map of paths intead of names (names can change)
@@ -158,12 +168,6 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
}
}
// last revoke api keys if needed.
if existingUser.Permissions.Api && !user.Permissions.Api && slices.Contains(fields, "Permissions") {
for _, key := range existingUser.ApiKeys {
auth.RevokeAPIKey(key.Key) // add to blacklist
}
}
return nil
}
@@ -180,7 +184,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
if err != nil {
return err
}
pass, err := users.HashPwd(user.Password)
pass, err := utils.HashPwd(user.Password)
if err != nil {
return err
}
@@ -193,7 +197,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, disableScopeChange)
err = files.MakeUserDirs(user, true)
if err != nil {
logger.Error(err.Error())
}
@@ -257,7 +261,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
field := t.Field(i)
// which=all can't update password
switch strings.ToLower(field.Name) {
case "id", "username", "password", "apikeys", "totpsecret", "totpnonce":
case "id", "username", "password", "tokens", "totpsecret", "totpnonce":
// Skip these fields
continue
}
@@ -299,7 +303,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
if err != nil {
return nil, fmt.Errorf("password does not meet complexity requirements")
}
value, err := users.HashPwd(user.Password)
value, err := utils.HashPwd(user.Password)
if err != nil {
logger.Error(err.Error())
}
+2 -1
View File
@@ -6,11 +6,12 @@ import (
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
func init() {
users.BcryptCost = 4 // bcrypt.MinCost for faster tests
utils.BcryptCost = 4 // bcrypt.MinCost for faster tests
// Initialize test source configuration
settings.Config.Server.SourceMap = map[string]*settings.Source{
+4 -1
View File
@@ -18,6 +18,7 @@ import (
var userStore *users.Storage
func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
logger.Debugf("Initializing database: %v", path)
exists, err := dbExists(path)
if err != nil {
panic(err)
@@ -36,6 +37,8 @@ func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
// Load access rules from DB on startup
// ignoring errors because
_ = store.Access.LoadFromDB()
// Load share cache from DB so shareByHash/shareByPath are populated at startup
store.Share.LoadShareCacheFromDB()
userStore = store.Users
err = bolt.Save(db, "version", 2)
if err != nil {
@@ -115,7 +118,7 @@ func CreateUser(userInfo users.User, permissions users.Permissions) error {
return fmt.Errorf("password is required to create a password login user")
}
} else {
hashpass, err := users.HashPwd(userInfo.Username)
hashpass, err := utils.HashPwd(userInfo.Username)
if err != nil {
return err
}
+2 -2
View File
@@ -99,7 +99,7 @@ func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
newLinks := []SidebarLink{}
for _, link := range u.SidebarLinks {
// For source links, validate that the source exists using SourceName
if link.Category == "source" {
if strings.HasPrefix(link.Category, "source") {
if link.SourceName == "" {
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
}
@@ -126,7 +126,7 @@ func (u *User) GetFrontendSidebarLinks() []SidebarLink {
newLinks := []SidebarLink{}
for _, link := range u.SidebarLinks {
// For source links, validate that the source still exists
if link.Category == "source" {
if strings.HasPrefix(link.Category, "source") {
if link.SourceName == "" {
continue
}
-18
View File
@@ -1,18 +0,0 @@
package users
import (
"golang.org/x/crypto/bcrypt"
)
var BcryptCost = bcrypt.DefaultCost
// HashPwd hashes a password.
func HashPwd(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
return string(bytes), err
}
// CheckPwd checks if a password is correct.
func CheckPwd(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
+15 -14
View File
@@ -26,8 +26,8 @@ type Store interface {
Save(user *User, changePass bool, disableScopeChange bool) error
Delete(id interface{}) error
LastUpdate(id uint) int64
AddApiKey(username uint, name string, key AuthToken) error
DeleteApiKey(username uint, name string) error
AddApiToken(userID uint, name string, tokenString string, metadata AuthToken) error
DeleteApiToken(userID uint, name string) error
}
// crudBackend implements crud.CrudBackend[User] for users storage.
@@ -114,17 +114,18 @@ func (s *Storage) Update(user *User, adminIActor bool, fields ...string) error {
return nil
}
func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
func (s *Storage) AddApiToken(userID uint, name string, tokenString string, metadata AuthToken) error {
user, err := s.Get(userID)
if err != nil {
return err
}
// Initialize the ApiKeys map if it is nil
if user.ApiKeys == nil {
user.ApiKeys = make(map[string]AuthToken)
// Initialize the TokenHashes map if it is nil
if user.Tokens == nil {
user.Tokens = make(map[string]AuthToken)
}
user.ApiKeys[name] = key
err = s.Update(user, true, "ApiKeys")
metadata.Token = tokenString
user.Tokens[name] = metadata
err = s.Update(user, true, "Tokens")
if err != nil {
return err
}
@@ -132,17 +133,17 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
return nil
}
func (s *Storage) DeleteApiKey(userID uint, name string) error {
func (s *Storage) DeleteApiToken(userID uint, name string) error {
user, err := s.Get(userID)
if err != nil {
return err
}
// Initialize the ApiKeys map if it is nil
if user.ApiKeys == nil {
user.ApiKeys = make(map[string]AuthToken)
// Initialize the Tokens map if it is nil
if user.Tokens == nil {
user.Tokens = make(map[string]AuthToken)
}
delete(user.ApiKeys, name)
err = s.Update(user, true, "ApiKeys")
delete(user.Tokens, name)
err = s.Update(user, true, "Tokens")
if err != nil {
return err
}
+18 -8
View File
@@ -15,13 +15,18 @@ const (
LoginMethodPassword LoginMethod = "password"
LoginMethodProxy LoginMethod = "proxy"
LoginMethodOidc LoginMethod = "oidc"
LoginMethodLdap LoginMethod = "ldap"
LoginMethodJwt LoginMethod = "jwt"
)
type AuthToken struct {
MinimalAuthToken
Key string `json:"key,omitempty"`
Key string `json:"key,omitempty"` // for backward compatibility
Token string `json:"token,omitempty"`
Name string `json:"name,omitempty"`
BelongsTo uint `json:"belongsTo,omitempty"`
IssuedAt int64 `json:"issuedAt,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
Permissions Permissions `json:"Permissions,omitempty"`
}
@@ -49,15 +54,16 @@ type Sorting struct {
type Preview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // disable the hide sidebar preview for previews and editors
HighQuality bool `json:"highQuality"` // generate high quality thumbnail preview images
Image bool `json:"image"` // show thumbnail preview image for image files
Video bool `json:"video"` // show thumbnail preview image for video files
Audio bool `json:"audio"` // show thumbnail preview image for audio files
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office bool `json:"office"` // show thumbnail preview image for office files
PopUp bool `json:"popup"` // show larger popup preview when hovering over thumbnail
AutoplayMedia bool `json:"autoplayMedia"` // autoplay media files in preview
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable html5 media player and use the default media player
Folder bool `json:"folder"` // show thumbnail preview image for folder files
Models bool `json:"models"` // show live thumbnails for 3D models files
}
// User describes a user.
@@ -70,15 +76,15 @@ type User struct {
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
Version int `json:"version"`
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"`
Version int `json:"version"`
ShowFirstLogin bool `json:"showFirstLogin"`
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
}
type SourceScope struct {
@@ -114,10 +120,14 @@ type NonAdminEditable struct {
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order instead
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
ShowFirstLogin bool `json:"showFirstLogin"`
}
type FileLoading struct {
@@ -130,7 +140,7 @@ type FileLoading struct {
// SidebarLink represents a customizable link in the sidebar.
type SidebarLink struct {
Name string `json:"name"` // Display name of the link
Category string `json:"category"` // Category type: "source", "share", "tool", "custom", etc.
Category string `json:"category"` // Category type: "source", "source-link", "share", "tool", "custom", etc.
Target string `json:"target"` // Target path/URL for the link (relative for source/share)
Icon string `json:"icon"` // Material icon name
SourceName string `json:"sourceName,omitempty"` // Source identifier for source-type links
-4646
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -3,11 +3,12 @@ package ffmpeg
import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-cache/cache"
)
var (
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
MediaCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
)
+29
View File
@@ -0,0 +1,29 @@
package ffmpeg
import "github.com/gtsteffaniak/filebrowser/backend/common/settings"
func CanConvertImage(ext string) bool {
if !MediaEnabled() {
return false
}
val := settings.Config.Integrations.Media.Convert.ImagePreview[settings.ImagePreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
}
func CanConvertVideo(ext string) bool {
if !MediaEnabled() {
return false
}
val := settings.Config.Integrations.Media.Convert.VideoPreview[settings.VideoPreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
}
func MediaEnabled() bool {
return settings.Env.FFmpegPath != "" && settings.Env.FFprobePath != ""
}
+14 -2
View File
@@ -11,12 +11,16 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
// GetImageOrientation extracts the EXIF orientation from an image file using exiftool
func (s *FFmpegService) GetImageOrientation(imagePath string) (string, error) {
// Use exiftool to get orientation information
cmd := exec.Command("exiftool", "-Orientation", "-s3", imagePath)
exiftoolPath := settings.Config.Integrations.Media.ExiftoolPath
if exiftoolPath == "" {
return "Horizontal (normal)", nil
}
cmd := exec.Command(exiftoolPath, "-Orientation", "-s3", imagePath)
var out bytes.Buffer
var errOut bytes.Buffer
cmd.Stdout = &out
@@ -378,3 +382,11 @@ func (s *FFmpegService) ConvertHEICToJPEG(ctx context.Context, heicPath string,
return jpegBytes, nil
}
// ConvertImageToJPEG converts any image format (including problematic JPEGs) to standard JPEG using FFmpeg
// This handles extended JPEG formats that Go's image/jpeg decoder doesn't support (e.g., SOF1/C1 marker)
// and can be used as a fallback for images that fail to decode with Go's standard library.
func (s *FFmpegService) ConvertImageToJPEG(ctx context.Context, imagePath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
// Use the direct conversion method which works for all image formats
return s.ConvertHEICToJPEGDirect(ctx, imagePath, targetWidth, targetHeight, quality)
}
+33 -136
View File
@@ -10,20 +10,10 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// SubtitleTrack represents a subtitle track (embedded or external file)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language,omitempty"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Content string `json:"content,omitempty"` // subtitle content
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
}
// FFProbeOutput represents the JSON output from ffprobe
type FFProbeOutput struct {
Streams []struct {
@@ -36,7 +26,7 @@ type FFProbeOutput struct {
}
// DetectAllSubtitles finds both embedded and external subtitle tracks
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []SubtitleTrack {
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []utils.SubtitleTrack {
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
// Check cache first
@@ -44,7 +34,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
return cached
}
var allSubtitles []SubtitleTrack
var allSubtitles []utils.SubtitleTrack
// First, get embedded subtitles
embeddedSubs := detectEmbeddedSubtitles(videoPath)
@@ -60,9 +50,25 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
return allSubtitles
}
// DetectEmbeddedSubtitles detects embedded subtitle streams using ffprobe.
// This is the public API that can be called from other packages.
// Returns empty array if ffprobe is not available or fails.
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
// Check cache first
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
if cached, ok := MediaCache.Get(key); ok {
return cached
}
// Detect embedded subtitles
subtitles := detectEmbeddedSubtitles(videoPath)
// Cache the results
MediaCache.Set(key, subtitles)
return subtitles
}
// detectEmbeddedSubtitles uses ffprobe to find embedded subtitle tracks
// Always runs ffprobe - results are cached for performance
func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-print_format", "json",
@@ -82,13 +88,13 @@ func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
return nil
}
var subtitles []SubtitleTrack
var subtitles []utils.SubtitleTrack
for _, stream := range probeOutput.Streams {
if stream.CodecType == "subtitle" {
track := SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
IsFile: false, // This is an embedded subtitle
track := utils.SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
Embedded: true, // This is an embedded subtitle
}
// Extract language and title from tags
@@ -117,8 +123,8 @@ func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
}
// detectExternalSubtitles finds external subtitle files in the same directory
func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack {
var subtitles []SubtitleTrack
func detectExternalSubtitles(videoPath string, parentDir string) []utils.SubtitleTrack {
var subtitles []utils.SubtitleTrack
// Get the base name of the video (without extension)
videoBaseName := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
@@ -131,9 +137,9 @@ func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack
subtitlePath := filepath.Join(parentDir, videoBaseName+ext)
if _, err := os.Stat(subtitlePath); err == nil {
// File exists
track := SubtitleTrack{
Name: filepath.Base(subtitlePath),
IsFile: true, // This is an external file
track := utils.SubtitleTrack{
Name: filepath.Base(subtitlePath),
Embedded: false, // This is an external file
}
// Try to infer language from filename patterns like "video.en.srt"
@@ -170,120 +176,11 @@ func ExtractSubtitleContent(videoPath string, streamIndex int) (string, error) {
return string(output), nil
}
// LoadAndConvertSubtitleFile loads a subtitle file and converts it to WebVTT format
func LoadAndConvertSubtitleFile(subtitlePath string) (string, error) {
ext := strings.ToLower(filepath.Ext(subtitlePath))
if ext == ".vtt" {
// Already WebVTT, just read it
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
if ext == ".srt" {
// Convert SRT to WebVTT using ffmpeg
cmd := exec.Command("ffmpeg",
"-i", subtitlePath,
"-c:s", "webvtt",
"-f", "webvtt",
"-") // output to stdout
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to convert SRT to WebVTT: %v", err)
}
return string(output), nil
}
// For other formats, try to read as plain text and hope for the best
// This is a fallback - ideally you'd want format-specific converters
// LoadSubtitleFile loads a subtitle file and returns its raw content
func LoadSubtitleFile(subtitlePath string) (string, error) {
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
// Basic conversion wrapper for non-VTT formats
vttHeader := "WEBVTT\n\n"
return vttHeader + string(content), nil
}
// ExtractSingleSubtitle extracts content for a specific subtitle track by array index
func ExtractSingleSubtitle(videoPath string, parentDir string, trackIndex int, modtime time.Time) (SubtitleTrack, error) {
// Get all subtitle tracks
allTracks := DetectAllSubtitles(videoPath, parentDir, modtime)
if trackIndex >= len(allTracks) {
return SubtitleTrack{}, fmt.Errorf("subtitle track %d not found (only %d tracks available)", trackIndex, len(allTracks))
}
track := allTracks[trackIndex]
// Load content based on type
if track.IsFile {
// Load external subtitle file
subtitlePath := filepath.Join(parentDir, track.Name)
content, err := LoadAndConvertSubtitleFile(subtitlePath)
if err != nil {
return SubtitleTrack{}, fmt.Errorf("failed to load external subtitle: %v", err)
}
track.Content = content
} else {
// Extract embedded subtitle content
if track.Index == nil {
return SubtitleTrack{}, fmt.Errorf("embedded subtitle track has no stream index")
}
content, err := ExtractSubtitleContent(videoPath, *track.Index)
if err != nil {
return SubtitleTrack{}, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
track.Content = content
}
return track, nil
}
// LoadAllSubtitleContent loads the actual content for all detected subtitle tracks
func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime time.Time) error {
for idx := range subtitles {
subtitle := &subtitles[idx]
// Check if content is already cached
contentKey := fmt.Sprintf("subtitle_content:%s:%d:%s", videoPath, idx, modtime.Format(time.RFC3339))
if cached, ok := SubtitleContentCache.Get(contentKey); ok {
subtitle.Content = cached
continue
}
var content string
var err error
if subtitle.IsFile {
// Load external subtitle file content and convert to WebVTT
subtitlePath := filepath.Join(filepath.Dir(videoPath), subtitle.Name)
content, err = LoadAndConvertSubtitleFile(subtitlePath)
if err != nil {
logger.Debug("failed to read/convert subtitle file " + subtitlePath + ": " + err.Error())
continue
}
} else {
// Load embedded subtitle content
if subtitle.Index == nil {
logger.Debug("embedded subtitle has no stream index")
continue
}
content, err = ExtractSubtitleContent(videoPath, *subtitle.Index)
if err != nil {
logger.Debug("failed to extract embedded subtitle: " + err.Error())
continue
}
}
subtitle.Content = content
// Cache the content for future requests
SubtitleContentCache.Set(contentKey, content)
}
return nil
return string(content), nil
}
+66 -63
View File
@@ -4,33 +4,35 @@ go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.17.0
github.com/coreos/go-systemd/v22 v22.6.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.15
github.com/go-playground/validator/v10 v10.29.0
github.com/goccy/go-yaml v1.19.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.2
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.0.2
github.com/gtsteffaniak/go-push v0.1.0
github.com/kovidgoyal/imaging v1.7.2
github.com/mattn/go-sqlite3 v1.14.32
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.21
github.com/mattn/go-sqlite3 v1.14.42
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.46.0
golang.org/x/image v0.34.0
golang.org/x/mod v0.31.0
golang.org/x/oauth2 v0.34.0
golang.org/x/sys v0.39.0
golang.org/x/time v0.14.0
golang.org/x/crypto v0.50.0
golang.org/x/image v0.39.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.43.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.44.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.40.1
modernc.org/sqlite v1.50.0
)
require (
@@ -48,20 +50,22 @@ require (
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.4.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.64.0 // indirect
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
github.com/air-verse/air v1.65.1 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.0.1 // 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.0 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -71,7 +75,7 @@ require (
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.3.0 // indirect
github.com/bombsimon/wsl/v5 v5.6.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
@@ -83,7 +87,7 @@ require (
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
@@ -97,28 +101,29 @@ require (
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/ghostiam/protogetter v0.3.18 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.22.2 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -128,21 +133,21 @@ require (
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.4.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.1 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20251215195340-f7ee789d9fc1 // indirect
github.com/golang/geo v0.0.0-20260415063119-550b242b3150 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.8.0 // indirect
github.com/golangci/golines v0.14.0 // indirect
github.com/golangci/misspell v0.7.0 // indirect
github.com/golangci/golangci-lint/v2 v2.11.4 // indirect
github.com/golangci/golines v0.15.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/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
@@ -162,12 +167,14 @@ require (
github.com/jgautheron/goconst v1.8.2 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // 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/jupiterrider/ffi v0.5.1 // indirect
github.com/jupiterrider/ffi v0.6.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
@@ -189,10 +196,10 @@ require (
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.13.0 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
@@ -201,11 +208,10 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.21.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polyfloyd/go-errorlint v1.8.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
@@ -222,15 +228,15 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.22.11 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.4.0 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/cobra v1.10.2 // indirect
@@ -251,7 +257,7 @@ require (
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/uudashr/gocognit v1.2.0 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.4.1 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
@@ -261,24 +267,21 @@ require (
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.11.1 // indirect
go.augendre.info/arangolint v0.3.1 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.6.1 // indirect
modernc.org/libc v1.67.1 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+171 -205
View File
@@ -59,11 +59,11 @@ github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksuf
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
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/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
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 v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
@@ -75,52 +75,41 @@ 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.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.2.1 h1:M+zdXMq84g+E1YOLa7g7ExN3dWfZQrdDSTCM7gC+m/A=
github.com/MirrexOne/unqueryvet v1.2.1/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
github.com/MirrexOne/unqueryvet v1.3.0 h1:5slWSomgqpYU4zFuZ3NNOfOUxVPlXFDBPAVasZOGlAY=
github.com/MirrexOne/unqueryvet v1.3.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
github.com/MirrexOne/unqueryvet v1.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ=
github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
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/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.63.1 h1:N6kD5niKKVx0wF2mW0mgK6LNfJqP5/lCAqm3WWl9vlw=
github.com/air-verse/air v1.63.1/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
github.com/air-verse/air v1.63.4 h1:Z+R4328Bja5QKFMTP0CNeT8aVWdb3D5kbbFvnXnuRhE=
github.com/air-verse/air v1.63.4/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
github.com/air-verse/air v1.63.7 h1:Wi85jUmyhGWcNpm/OUUvPiGNp65X+mUVKjr1N9HfZ3Q=
github.com/air-verse/air v1.63.7/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
github.com/air-verse/air v1.64.0 h1:lKEvIQzaOM5vAVKOdQxq1WrXLfLmzz+e+hPrhmgF6yo=
github.com/air-verse/air v1.64.0/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
github.com/air-verse/air v1.65.1 h1:G4hy4YemKrIcGAvYMor+86PhxdtBEq0n9ebJ4RoYy8w=
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
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.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
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/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
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.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw=
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
github.com/alexkohler/prealloc v1.0.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE=
github.com/alexkohler/prealloc v1.0.1/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
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.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
@@ -171,8 +160,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
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.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM=
github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -184,8 +173,6 @@ github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E
github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70=
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
github.com/catenacyber/perfsprint v0.10.0 h1:AZj1mYyxbxLRqmnYOeguZXEQwWOgQGm2wzLI5d7Hl/0=
github.com/catenacyber/perfsprint v0.10.0/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
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=
@@ -201,8 +188,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
@@ -216,10 +203,10 @@ github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyM
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -261,8 +248,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -271,8 +258,8 @@ 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.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
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.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
@@ -284,20 +271,18 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.17 h1:sjGPErP9o7i2Ym+z3LsQzBdLCNaqbYy2iJQPxGXg04Q=
github.com/ghostiam/protogetter v0.3.17/go.mod h1:AivIX1eKA/TcUmzZdzbl+Tb8tjIe8FcyG6JFyemQAH4=
github.com/ghostiam/protogetter v0.3.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo=
github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/go-critic/go-critic v0.14.2 h1:PMvP5f+LdR8p6B29npvChUXbD1vrNlKDf60NJtgMBOo=
github.com/go-critic/go-critic v0.14.2/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -309,51 +294,53 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -378,22 +365,18 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
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.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godoc-lint/godoc-lint v0.10.1 h1:ZPUVzlDtJfA+P688JfPJPkI/SuzcBr/753yGIk5bOPA=
github.com/godoc-lint/godoc-lint v0.10.1/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
github.com/godoc-lint/godoc-lint v0.10.2 h1:dksNgK+zebnVlj4Fx83CRnCmPO0qRat/9xfFsir1nfg=
github.com/godoc-lint/godoc-lint v0.10.2/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA=
github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8=
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.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM=
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -418,8 +401,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20251215195340-f7ee789d9fc1 h1:D/2kgdp+Hl79R59gNSnTQPpjWKd/IVUYhuvkqWQG8N0=
github.com/golang/geo v0.0.0-20251215195340-f7ee789d9fc1/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260415063119-550b242b3150 h1:F5CQANtNulclG+xJt370ohUujAEHq2XNBBAjkoSukQI=
github.com/golang/geo v0.0.0-20260415063119-550b242b3150/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -459,18 +442,12 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint/v2 v2.6.2 h1:jkMSVv36JmyTENcEertckvimvjPcD5qxNM7W7qhECvI=
github.com/golangci/golangci-lint/v2 v2.6.2/go.mod h1:fSIMDiBt9kzdpnvvV7GO6iWzyv5uaeZ+iPor+2uRczE=
github.com/golangci/golangci-lint/v2 v2.7.2 h1:AhBC+YeEueec4AGlIbvPym5C70Thx0JykIqXbdIXWx0=
github.com/golangci/golangci-lint/v2 v2.7.2/go.mod h1:pDijleoBu7e8sejMqyZ3L5n6geqe+cVvOAz2QImqqVc=
github.com/golangci/golangci-lint/v2 v2.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA=
github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI=
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8=
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ=
github.com/golangci/golines v0.14.0 h1:xt9d3RKBjhasA3qpoXs99J2xN2t6eBlpLHt0TrgyyXc=
github.com/golangci/golines v0.14.0/go.mod h1:gf555vPG2Ia7mmy2mzmhVQbVjuK8Orw0maR1G4vVAAQ=
github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/golangci-lint/v2 v2.11.4 h1:GK+UlZBN5y7rh2PBnHA93XLSX6RaF7uhzJQ3JwU1wuA=
github.com/golangci/golangci-lint/v2 v2.11.4/go.mod h1:ODQDCASMA3VqfZYIbbQLpTRTzV7O/vjmIRF6u8NyFwI=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
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=
@@ -504,8 +481,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -529,10 +506,8 @@ github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-logger v1.0.2 h1:S4AgR7yDu2geD8y2D9NyJVOBqpZRnl37r2mBi7GDNR0=
github.com/gtsteffaniak/go-logger v1.0.2/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/gtsteffaniak/go-push v0.1.0 h1:bMkin7Ce2xjTllPMk5q3IAxD2ZcMDGZICuQeeKDJDXo=
github.com/gtsteffaniak/go-push v0.1.0/go.mod h1:xZplgyS4bJdYmLbfEBbkETdrN/vQn6M/tim72fX2s1c=
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/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=
@@ -540,8 +515,6 @@ github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1T
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.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -555,6 +528,18 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@@ -565,6 +550,8 @@ github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjz
github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c=
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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
@@ -578,12 +565,12 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
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/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
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.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
@@ -591,8 +578,10 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/imaging v1.7.2 h1:mmT6k6Az3mC6dbqdZ6Q9KQCdZFWTAQ+q97NyGZgJ/2c=
github.com/kovidgoyal/imaging v1.7.2/go.mod h1:GdkCORjfZMMGFY0Pb7TDmRhj7PDhxF/QShKukSCj0VU=
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
github.com/kovidgoyal/imaging v1.8.21 h1:95S2+dowTeKJJHNpf6lnScvIennTr2H0zQotu+ptNQw=
github.com/kovidgoyal/imaging v1.8.21/go.mod h1:976F+zjiQeZ7sd87Pxlm0a64S/w9bImSIWg3sSk1rdQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -611,8 +600,6 @@ github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0
github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI=
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.7.1 h1:FaULkvUIG36hj6chpwa+FdCNGZBsD7/fO+p7CCsM6pE=
github.com/ldez/gomoddirectives v0.7.1/go.mod h1:auDNtakWJR1rC+YX7ar+HmveqXATBAyEK1KYpsIRW/8=
github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk=
github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q=
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
@@ -653,18 +640,16 @@ 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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.12.0 h1:Q+/kkbbwerrVYPv9d9efaPGmAO/NsxwW/nE6ahpQaCU=
github.com/mgechev/revive v1.12.0/go.mod h1:VXsY2LsTigk8XU9BpZauVLjVrhICMOV3k1lpB3CXrp8=
github.com/mgechev/revive v1.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM=
github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0=
github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A=
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=
@@ -696,8 +681,8 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
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/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po=
github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY=
github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8=
github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@@ -708,11 +693,10 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE=
github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
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=
@@ -734,12 +718,8 @@ 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q=
github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
@@ -789,8 +769,8 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
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/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU=
github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ=
github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg=
github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
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.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
@@ -799,12 +779,8 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
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.22.10 h1:ntbBqdWXnu46DUOXn+R2SvPo3PiJCDugTCgTW2g4tQg=
github.com/securego/gosec/v2 v2.22.10/go.mod h1:9UNjK3tLpv/w2b0+7r82byV43wCJDNtEDQMeS+H/g2w=
github.com/securego/gosec/v2 v2.22.11-0.20251204091113-daccba6b93d7 h1:rZg6IGn0ySYZwCX8LHwZoYm03JhG/cVAJJ3O+u3Vclo=
github.com/securego/gosec/v2 v2.22.11-0.20251204091113-daccba6b93d7/go.mod h1:9sr22NZO5Kfh7unW/xZxkGYTmj2484/fCiE54gw7UTY=
github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg=
github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
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/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
@@ -813,20 +789,18 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
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.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
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.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
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.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
@@ -839,8 +813,6 @@ github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ=
github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
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.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4=
github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk=
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=
@@ -850,7 +822,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
@@ -879,8 +850,6 @@ github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU=
github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU=
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/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
@@ -891,8 +860,8 @@ github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSW
github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA=
github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU=
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/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
@@ -903,6 +872,8 @@ github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pH
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
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=
@@ -928,8 +899,8 @@ 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.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
go.augendre.info/arangolint v0.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo=
go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g=
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.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
@@ -940,8 +911,6 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
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.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
@@ -959,8 +928,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -971,16 +940,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
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-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -1006,8 +975,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1050,16 +1019,16 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1075,8 +1044,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1121,17 +1090,15 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
@@ -1148,13 +1115,13 @@ 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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -1205,8 +1172,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
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=
@@ -1312,7 +1279,6 @@ gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1322,32 +1288,32 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+127 -161
View File
@@ -3,90 +3,52 @@ package http
import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var (
ApiTokenUserCache = cache.NewCache[uint](24 * time.Hour) // API token to user ID mapping gets cached for 24 hours
)
// getUserFromApiToken finds the user ID associated with a given API token
func getUserFromApiToken(token string) (uint, error) {
// Check cache first
if cached, ok := ApiTokenUserCache.Get(token); ok {
return cached, nil
}
// Get all users
allUsers, err := store.Users.Gets()
if err != nil {
return 0, fmt.Errorf("failed to get all users: %w", err)
}
// Iterate through all users and their API keys
for _, user := range allUsers {
if user.ApiKeys == nil {
continue
}
for _, apiKey := range user.ApiKeys {
if apiKey.Key == token {
// Cache the result
ApiTokenUserCache.Set(token, user.ID)
return user.ID, nil
}
}
}
// Token not found
return 0, fmt.Errorf("token not found")
}
// createApiKeyHandler creates an API key for the user.
// @Summary Create API key
// @Description Create an API key with specified name, duration, and permissions.
// @Tags API Keys
// createApiTokenHandler creates an API token for the user.
// @Summary Create API Token
// @Description Create an API token with specified name, duration, and permissions.
// @Tags Auth
// @Accept json
// @Produce json
// @Param name query string true "Name of the API key"
// @Param days query string true "Duration of the API key in days"
// @Param permissions query string true "Permissions for the API key (comma-separated)"
// @Success 200 {object} HttpResponse "Token created successfully, response contains json object with token key"
// @Param name query string true "Name of the API token"
// @Param days query string true "Duration of the API token in days"
// @Param permissions query string true "Permissions for the API token (comma-separated)"
// @Success 200 {object} HttpResponse "Token created successfully, response contains json object with token"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 409 {object} map[string]string "Conflict"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/token [put]
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// @Router /api/auth/token [post]
func createApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
durationStr := r.URL.Query().Get("days")
permissionsStr := r.URL.Query().Get("permissions")
minimal := r.URL.Query().Get("minimal") == "true"
minimal := permissionsStr == ""
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api keys")
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api tokens")
}
if name == "" {
return http.StatusBadRequest, fmt.Errorf("api name must be valid")
if name == "" || strings.HasPrefix(name, "WEB_TOKEN") {
return http.StatusBadRequest, fmt.Errorf("api token name must be valid")
}
if durationStr == "" {
return http.StatusBadRequest, fmt.Errorf("api duration must be valid")
return http.StatusBadRequest, fmt.Errorf("api token duration must be valid")
}
// For full tokens (minimal=false), permissions are required in the claim
// For minimal tokens (minimal=true), permissions are not in the token
var permissions users.Permissions
if !minimal {
if permissionsStr == "" {
return http.StatusBadRequest, fmt.Errorf("api permissions must be valid for full tokens")
}
// Parse permissions from the query parameter
permissions = users.Permissions{
Api: strings.Contains(permissionsStr, "api") && d.user.Permissions.Api,
@@ -108,156 +70,160 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Here we assume the duration is in seconds; convert to time.Duration
duration := time.Duration(durationInt) * time.Hour * 24
// get request body like:
token, err := makeSignedTokenAPI(d.user, name, duration, permissions, minimal)
tokenString, authToken, err := auth.MakeSignedTokenAPI(d.user, name, duration, permissions, minimal)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
}
return http.StatusInternalServerError, err
}
// Store API token metadata in user's Tokens map
err = store.Users.AddApiToken(d.user.ID, name, tokenString, authToken)
if err != nil {
return http.StatusInternalServerError, err
}
// Store token hash → user ID mapping in access storage for fast lookups
err = store.Access.AddApiToken(tokenString, d.user.ID)
if err != nil {
return http.StatusInternalServerError, err
}
response := HttpResponse{
Message: "here is your token!",
Token: token.Key,
Token: tokenString,
}
return renderJSON(w, r, response)
}
// deleteApiKeyHandler deletes an API key for the user.
// @Summary Delete API key
// @Description Delete an API key with specified name.
// @Tags API Keys
// deleteApiTokenHandler deletes an API token for the user.
// @Summary Delete API token
// @Description Delete an API token with specified name.
// @Tags Auth
// @Accept json
// @Produce json
// @Param name query string true "Name of the API key to delete"
// @Success 200 {object} HttpResponse "API key deleted successfully"
// @Param name query string true "Name of the API token to delete"
// @Success 200 {object} HttpResponse "API token deleted successfully"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/token [delete]
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
func deleteApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api keys")
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api tokens")
}
keyInfo, ok := d.user.ApiKeys[name]
tokenInfo, ok := d.user.Tokens[name]
if !ok {
return http.StatusNotFound, fmt.Errorf("api key not found")
return http.StatusNotFound, fmt.Errorf("api token not found")
}
// Perform the user update
err := store.Users.DeleteApiKey(d.user.ID, name)
err := store.Users.DeleteApiToken(d.user.ID, name)
if err != nil {
return http.StatusNotFound, err
}
auth.RevokeAPIKey(keyInfo.Key) // add to blacklist
// Revoke the token (adds to RevokedTokens set)
if err := auth.RevokeApiToken(store.Access, tokenInfo.Token); err != nil {
logger.Errorf("Failed to revoke token: %v", err)
}
if err := store.Access.RemoveApiToken(tokenInfo.Token); err != nil {
logger.Errorf("Failed to remove api token: %v", err)
}
response := HttpResponse{
Message: "successfully deleted api key from user",
Message: "successfully deleted api token from user",
}
return renderJSON(w, r, response)
}
type AuthTokenMin struct {
Key string `json:"key"`
type AuthTokenFrontend struct {
Token string `json:"token"`
Name string `json:"name"`
Created int64 `json:"created"`
Expires int64 `json:"expires"`
IssuedAt int64 `json:"issuedAt"`
ExpiresAt int64 `json:"expiresAt"`
Permissions users.Permissions `json:"Permissions,omitempty"`
Minimal bool `json:"minimal"`
}
// listApiKeysHandler lists all API keys or retrieves details for a specific key.
// @Summary List API keys
// @Description List all API keys or retrieve details for a specific key.
// @Tags API Keys
// authTokenIssuedUnix returns iat from JWT claims when set, otherwise the persisted int64 (e.g. loaded from DB).
func authTokenIssuedUnix(t users.AuthToken) int64 {
if t.RegisteredClaims.IssuedAt != nil {
return t.RegisteredClaims.IssuedAt.Unix()
}
return t.IssuedAt
}
// authTokenExpiresUnix returns exp from JWT claims when set, otherwise the persisted int64.
func authTokenExpiresUnix(t users.AuthToken) int64 {
if t.RegisteredClaims.ExpiresAt != nil {
return t.RegisteredClaims.ExpiresAt.Unix()
}
return t.ExpiresAt
}
// listApiTokensHandler lists all API tokens or retrieves details for a specific token.
// @Summary List API tokens
// @Description List all API tokens or retrieve details for a specific token.
// @Tags Auth
// @Accept json
// @Produce json
// @Param name query string false "Name of the API to retrieve details"
// @Success 200 {object} AuthTokenMin "List of API keys or specific key details"
// @Success 200 {array} AuthTokenFrontend "List of API tokens"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/tokens [get]
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
key := r.URL.Query().Get("name")
// @Router /api/auth/token/list [get]
func listApiTokensHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api keys")
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api tokens")
}
if key != "" {
keyInfo, ok := d.user.ApiKeys[key]
if !ok {
return http.StatusNotFound, fmt.Errorf("api key not found")
}
// Determine if token is minimal by parsing the JWT claim
// For minimal tokens, the JWT claim doesn't have BelongsTo even though the database record does
minimal := false
if keyInfo.Key != "" {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(config.Auth.Key), nil
}
var claim users.AuthToken
token, err := jwt.ParseWithClaims(keyInfo.Key, &claim, keyFunc)
if err == nil && token.Valid {
// If the parsed claim doesn't have BelongsTo, it's a minimal token
minimal = claim.BelongsTo == 0
}
}
modifiedKey := AuthTokenMin{
Key: keyInfo.Key,
Name: key,
Created: 0,
Expires: 0,
Minimal: minimal,
}
// Safely access IssuedAt and ExpiresAt (they're pointers)
if keyInfo.IssuedAt != nil {
modifiedKey.Created = keyInfo.IssuedAt.Unix()
}
if keyInfo.ExpiresAt != nil {
modifiedKey.Expires = keyInfo.ExpiresAt.Unix()
}
// Only include Permissions for full tokens (not minimal tokens)
if !minimal {
modifiedKey.Permissions = keyInfo.Permissions
}
return renderJSON(w, r, modifiedKey)
if len(d.user.Tokens) == 0 {
return http.StatusNotFound, fmt.Errorf("no api tokens found")
}
modifiedList := map[string]AuthTokenMin{}
for key, value := range d.user.ApiKeys {
// Determine if token is minimal by parsing the JWT claim
// For minimal tokens, the JWT claim doesn't have BelongsTo even though the database record does
minimal := false
if value.Key != "" {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(config.Auth.Key), nil
}
var claim users.AuthToken
token, err := jwt.ParseWithClaims(value.Key, &claim, keyFunc)
if err == nil && token.Valid {
// If the parsed claim doesn't have BelongsTo, it's a minimal token
minimal = claim.BelongsTo == 0
}
}
modifiedKey := AuthTokenMin{
Key: value.Key,
Created: 0,
Expires: 0,
Minimal: minimal,
}
// Safely access IssuedAt and ExpiresAt (they're pointers)
if value.IssuedAt != nil {
modifiedKey.Created = value.IssuedAt.Unix()
}
if value.ExpiresAt != nil {
modifiedKey.Expires = value.ExpiresAt.Unix()
}
// Only include Permissions for full tokens (not minimal tokens)
if !minimal {
modifiedKey.Permissions = value.Permissions
}
modifiedList[key] = modifiedKey
AuthTokensFrontend := make([]AuthTokenFrontend, 0, len(d.user.Tokens))
for name, token := range d.user.Tokens {
AuthTokensFrontend = append(AuthTokensFrontend, AuthTokenFrontend{
Token: token.Token,
Name: name,
IssuedAt: authTokenIssuedUnix(token),
ExpiresAt: authTokenExpiresUnix(token),
Permissions: token.Permissions,
})
}
sort.Slice(AuthTokensFrontend, func(i, j int) bool {
return AuthTokensFrontend[i].Name < AuthTokensFrontend[j].Name
})
return renderJSON(w, r, modifiedList)
return renderJSON(w, r, AuthTokensFrontend)
}
// getApiTokenHandler gets a specific API token.
// @Summary Get API token
// @Description Get a specific API token.
// @Tags Auth
// @Accept json
// @Produce json
// @Param name query string true "Name of the API token to retrieve"
// @Success 200 {object} AuthTokenFrontend "API token details"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/token [get]
func getApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api tokens")
}
tokenInfo, ok := d.user.Tokens[name]
if !ok {
return http.StatusNotFound, fmt.Errorf("api token not found")
}
AuthTokenFrontendResponse := AuthTokenFrontend{
Token: tokenInfo.Token,
Name: name,
IssuedAt: authTokenIssuedUnix(tokenInfo),
ExpiresAt: authTokenExpiresUnix(tokenInfo),
Permissions: tokenInfo.Permissions,
}
return renderJSON(w, r, AuthTokenFrontendResponse)
}
File diff suppressed because it is too large. Load diff
+289
View File
@@ -0,0 +1,289 @@
package http
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeArchiveEntryName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErr string
}{
{
name: "simple forward slashes",
input: "DOCS/guide.pdf",
want: "DOCS/guide.pdf",
},
{
name: "Windows backslashes become logical path",
input: `DOCS\QUICK\chs.gif`,
want: "DOCS/QUICK/chs.gif",
},
{
name: "Intel-style leading backslash stripped so path stays relative",
input: `\PRO1000\Winx64\NDIS65\e1c65x64.cat`,
want: "PRO1000/Winx64/NDIS65/e1c65x64.cat",
},
{
name: "leading forward slashes stripped",
input: "/PRO1000/Winx64/readme.txt",
want: "PRO1000/Winx64/readme.txt",
},
{
name: "directory entry with trailing slash",
input: `PRO1000\Winx64\`,
want: "PRO1000/Winx64",
},
{
name: "mixed redundant segments cleaned",
input: "a/./b/../c/file",
want: "a/c/file",
},
{
name: "empty",
input: "",
wantErr: "empty path",
},
{
name: "whitespace only",
input: " ",
wantErr: "empty path",
},
{
name: "UNC rejected",
input: `//server/share/file`,
wantErr: "UNC or invalid path",
},
{
name: "double slash prefix after backslash replace (UNC-style)",
input: `\\?\C:\temp\file`,
wantErr: "UNC or invalid path",
},
{
name: "Windows drive absolute rejected",
input: `C:/Windows/system32`,
wantErr: "absolute path in archive",
},
{
name: "lowercase drive rejected",
input: `c:/data/file.txt`,
wantErr: "absolute path in archive",
},
{
name: "bare dot-dot",
input: "..",
wantErr: "path traversal",
},
{
name: "prefixed dot-dot",
input: "../etc/passwd",
wantErr: "path traversal",
},
{
name: "traversal after path clean",
input: "safe/../../outside",
wantErr: "path traversal after clean",
},
{
name: "only dots and slashes",
input: "./",
wantErr: "empty path after clean",
},
{
name: "path cleans to nothing",
input: "x/../.",
wantErr: "empty path after clean",
},
{
name: "spaces preserved in file name",
input: "My Folder/read me.txt",
want: "My Folder/read me.txt",
},
{
name: "deep relative tree",
input: "a/b/c/d/e/f/g.bin",
want: "a/b/c/d/e/f/g.bin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeArchiveEntryName(tt.input)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil (result %q)", tt.wantErr, got)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("normalizeArchiveEntryName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
// TestNormalizeArchiveEntryName_backslashInFileNameOnUnix documents that after normalization
// a single backslash in the raw name is never a path separator: it becomes a slash in the
// logical path (portable), so segments split correctly on every GOOS in safeExtractPath.
func TestNormalizeArchiveEntryName_unifiesBackslashes(t *testing.T) {
t.Parallel()
got, err := normalizeArchiveEntryName(`folder\file.txt`)
if err != nil {
t.Fatal(err)
}
if want := "folder/file.txt"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestSafeExtractPath(t *testing.T) {
t.Parallel()
dest := t.TempDir()
dest, err := filepath.Abs(dest)
if err != nil {
t.Fatal(err)
}
t.Run("file under dest", func(t *testing.T) {
t.Parallel()
got, err := safeExtractPath(dest, `DOCS\Adapter_User_Guide.pdf`)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(dest, "DOCS", "Adapter_User_Guide.pdf")
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("leading backslash in zip name stays under dest", func(t *testing.T) {
t.Parallel()
got, err := safeExtractPath(dest, `\sub\file.txt`)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got, dest+string(filepath.Separator)) {
t.Fatalf("result %q should be under dest %q", got, dest)
}
rel, err := filepath.Rel(dest, got)
if err != nil {
t.Fatal(err)
}
if rel != filepath.Join("sub", "file.txt") {
t.Fatalf("Rel = %q, want sub%cfile.txt", rel, filepath.Separator)
}
})
t.Run("rejects path that escapes dest", func(t *testing.T) {
t.Parallel()
_, err := safeExtractPath(dest, "../../../x")
if err == nil {
t.Fatal("expected error for parent segments in archive name")
}
if !strings.Contains(err.Error(), "invalid entry path") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("rejects when normalize fails", func(t *testing.T) {
t.Parallel()
_, err := safeExtractPath(dest, "..")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "invalid entry path") {
t.Fatalf("got %v", err)
}
})
}
// TestSafeExtractPath_rejectsParentTraversal ensures ".." in the archive name is rejected
// by normalizeArchiveEntryName, not written under dest.
func TestSafeExtractPath_rejectsParentTraversal(t *testing.T) {
t.Parallel()
dest := t.TempDir()
_, err := safeExtractPath(dest, `pkg/../../../secret`)
if err == nil {
t.Fatal("expected error for path traversal in archive name")
}
}
// TestSafeExtractPath_destJoinDoesNotDropBase verifies that a previously problematic pattern
// (root-relative second segment) does not throw away dest after normalization.
func TestSafeExtractPath_destJoinDoesNotDropBase(t *testing.T) {
t.Parallel()
dest := t.TempDir()
out, err := safeExtractPath(dest, `a\b\c.txt`)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(out, dest+string(filepath.Separator)) {
t.Fatalf("expected path under %q, got %q", dest, out)
}
}
// TestExtractZip_nestedBackslashes extracts a Windows-style path in a zip (backslashes) into
// nested directories under dest. This matches many vendor driver zips.
func TestExtractZip_nestedBackslashes(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "test.zip")
out := filepath.Join(tmp, "out")
if err := os.MkdirAll(out, 0o755); err != nil {
t.Fatal(err)
}
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
w, err := zw.Create(`DOCS\QUICK\readme.txt`)
if err != nil {
t.Fatal(err)
}
if _, err = w.Write([]byte("hello")); err != nil {
t.Fatal(err)
}
if err = zw.Close(); err != nil {
t.Fatal(err)
}
if err = f.Close(); err != nil {
t.Fatal(err)
}
if err = extractZip(zipPath, out); err != nil {
t.Fatalf("extractZip: %v", err)
}
b, err := os.ReadFile(filepath.Join(out, "DOCS", "QUICK", "readme.txt"))
if err != nil {
t.Fatal(err)
}
if string(b) != "hello" {
t.Fatalf("content = %q", b)
}
}
// Benchmarks (optional, keep small)
func BenchmarkNormalizeArchiveEntryName(b *testing.B) {
const name = `PRO1000\Winx64\NDIS65\e1c65x64.cat`
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = normalizeArchiveEntryName(name)
}
}
+129 -112
View File
@@ -8,11 +8,9 @@ import (
libError "errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
@@ -88,25 +86,22 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
if err.Error() != "the resource does not exist" {
return nil, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
user := users.User{
LoginMethod: users.LoginMethodProxy,
Username: proxyUser,
}
settings.ApplyUserDefaults(&user)
if user.Username == config.Auth.AdminUsername {
user.Permissions.Admin = true
}
err = storage.CreateUser(user, user.Permissions)
if err != nil {
return nil, err
}
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("proxy authentication failed - no user found")
// Auto-create user on first proxy authentication
user := users.User{
LoginMethod: users.LoginMethodProxy,
Username: proxyUser,
}
settings.ApplyUserDefaults(&user)
if user.Username == config.Auth.AdminUsername {
user.Permissions.Admin = true
}
err = storage.CreateUser(user, user.Permissions)
if err != nil {
return nil, err
}
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return nil, err
}
}
if data.user.LoginMethod != users.LoginMethodProxy {
@@ -122,6 +117,99 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
return data.user, nil
}
// setupJwtUser retrieves or creates a user based on external JWT token claims
func setupJwtUser(r *http.Request, data *requestContext, username string, claims map[string]interface{}) (*users.User, error) {
var err error
// Retrieve the user from the store
data.user, err = store.Users.Get(username)
if err != nil {
if err.Error() != "the resource does not exist" {
return nil, err
}
// Auto-create user on first JWT authentication
user := users.User{
LoginMethod: users.LoginMethodJwt,
Username: username,
}
settings.ApplyUserDefaults(&user)
// Check if user should be admin based on groups
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
for _, group := range groups {
if group == config.Auth.Methods.JwtAuth.AdminGroup {
user.Permissions.Admin = true
break
}
}
}
// Also check if username matches admin username
if user.Username == config.Auth.AdminUsername {
user.Permissions.Admin = true
}
err = storage.CreateUser(user, user.Permissions)
if err != nil {
return nil, err
}
data.user, err = store.Users.Get(username)
if err != nil {
return nil, err
}
}
// Verify login method matches
if data.user.LoginMethod != users.LoginMethodJwt {
return nil, errors.ErrWrongLoginMethod
}
// Check if user is in allowed groups (if UserGroups is configured)
if len(config.Auth.Methods.JwtAuth.UserGroups) > 0 {
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
allowed := false
for _, userGroup := range groups {
for _, allowedGroup := range config.Auth.Methods.JwtAuth.UserGroups {
if userGroup == allowedGroup {
allowed = true
break
}
}
if allowed {
break
}
}
if !allowed {
return nil, fmt.Errorf("user is not in allowed groups")
}
}
// Update admin status if needed
shouldBeAdmin := false
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
for _, group := range groups {
if group == config.Auth.Methods.JwtAuth.AdminGroup {
shouldBeAdmin = true
break
}
}
}
if data.user.Username == config.Auth.AdminUsername {
shouldBeAdmin = true
}
if shouldBeAdmin != data.user.Permissions.Admin {
data.user.Permissions.Admin = shouldBeAdmin
err = store.Users.Update(data.user, true, "Permissions")
if err != nil {
return nil, err
}
}
return data.user, nil
}
// loginHandler handles user authentication via password.
// @Summary User login
// @Description Authenticate a user with a username and password. The password must be URL-encoded and sent in the X-Password header to support special characters (e.g., ^, %, £, €, etc.).
@@ -137,16 +225,13 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/login [post]
func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.user.LoginMethod == users.LoginMethodProxy {
return printToken(w, r, d.user)
}
passwordUser := d.user.LoginMethod == users.LoginMethodPassword
enforcedOtp := config.Auth.Methods.PasswordAuth.EnforcedOtp
missingOtp := d.user.TOTPSecret == ""
if passwordUser && enforcedOtp && missingOtp {
return http.StatusForbidden, errors.ErrNoTotpConfigured
}
return printToken(w, r, d.user) // Pass the data object
return printToken(w, r, d.user)
}
// logoutHandler handles user logout
@@ -158,7 +243,9 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
// @Success 200 {object} map[string]string "{"logoutUrl": "http://..."}"
// @Router /api/auth/logout [post]
func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
defer auth.RevokeAPIKey(d.token)
if err := auth.RevokeApiToken(store.Access, d.token); err != nil {
logger.Errorf("Failed to revoke token on logout: %v", err)
}
// Clear the authentication cookie by setting it to expire in the past
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
@@ -171,7 +258,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
Value: "",
Domain: strings.Split(host, ":")[0],
Path: "/",
SameSite: http.SameSiteLaxMode,
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0), // Expire immediately
MaxAge: -1, // Delete cookie
}
@@ -188,6 +275,16 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
if oidcRedirectUrl != "" {
logoutUrl = oidcRedirectUrl
}
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodLdap {
ldapRedirectUrl := config.Auth.Methods.LdapAuth.LogoutRedirectUrl
if ldapRedirectUrl != "" {
logoutUrl = ldapRedirectUrl
}
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodJwt {
jwtRedirectUrl := config.Auth.Methods.JwtAuth.LogoutRedirectUrl
if jwtRedirectUrl != "" {
logoutUrl = jwtRedirectUrl
}
}
if logoutUrl == "" {
logger.Debug("no logout url found, using default")
@@ -258,7 +355,7 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
// @Success 200 {string} string "New JWT token generated"
// @Failure 401 {object} map[string]string "Unauthorized - invalid token"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/renew [post]
// @Router /api/auth/renew [post]
func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// check if x-auth header is present and token is
return printToken(w, r, d.user)
@@ -266,7 +363,7 @@ func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int, error) {
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
tokenString, _, err := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
@@ -278,90 +375,16 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int,
// This allows backend to identify expired sessions and provide better user feedback
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
setSessionCookie(w, r, signed.Key, expiresTime)
setSessionCookie(w, r, tokenString, expiresTime)
// Still return token in body for backward compatibility and state management
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte(signed.Key)); err != nil {
if _, err := w.Write([]byte(tokenString)); err != nil {
return 401, errors.ErrUnauthorized
}
return 0, nil
}
func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions, minimal bool) (users.AuthToken, error) {
_, ok := user.ApiKeys[name]
if ok {
return users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
}
now := time.Now()
expires := now.Add(duration)
var tokenString string
var err error
if minimal {
// Create minimal token with only JWT standard claims
minimalClaim := users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: "FileBrowser Quantum",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, minimalClaim)
tokenString, err = token.SignedString([]byte(config.Auth.Key))
if err != nil {
return users.AuthToken{}, err
}
} else {
// Create full token with permissions and user ID
fullClaim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: "FileBrowser Quantum",
},
},
Name: name,
Permissions: perms,
BelongsTo: user.ID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, fullClaim)
tokenString, err = token.SignedString([]byte(config.Auth.Key))
if err != nil {
return users.AuthToken{}, err
}
}
// Create the AuthToken to store in database (always includes permissions and user ID)
storedClaim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: "FileBrowser Quantum",
},
},
Key: tokenString,
Name: name,
Permissions: perms,
BelongsTo: user.ID,
}
if strings.HasPrefix(name, "WEB_TOKEN") {
// don't add to api tokens, its a short lived web token
return storedClaim, nil
}
// Perform the user update
err = store.Users.AddApiKey(user.ID, name, storedClaim)
if err != nil {
return storedClaim, err
}
return storedClaim, nil
}
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
if l.PasswordHash == "" {
return 200, nil
@@ -398,10 +421,6 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
}
password := r.Header.Get("X-SHARE-PASSWORD")
password, err := url.QueryUnescape(password)
if err != nil {
return http.StatusUnauthorized, err
}
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
@@ -423,10 +442,8 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expi
Value: token,
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
Path: "/",
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
Expires: expiresTime,
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
// Secure: true, // Enable this in production with HTTPS
}
http.SetCookie(w, cookie)
}
Loaded 100 of 399 files, more files were not shown because too many files have changed in this diff. Show more