mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-15 05:31:59 -05:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5597b28f10 | ||
|
|
6bfc397419 | ||
|
|
112740bdd4 | ||
|
|
9117fb2b5f | ||
|
|
668efec4d7 | ||
|
|
a5c81c8e2e | ||
|
|
88aa337696 | ||
|
|
9b48ce414e | ||
|
|
0723edf660 | ||
|
|
d2165ec0c3 | ||
|
|
78c554719f | ||
|
|
aa8f9e2923 | ||
|
|
3bfd9d9be0 | ||
|
|
ce5269e8e9 | ||
|
|
6d21c7918e | ||
|
|
2693026f6c | ||
|
|
eb4dfa5710 | ||
|
|
363619bcb4 | ||
|
|
ab9790427f | ||
|
|
eabdfd9aa2 | ||
|
|
af08800667 | ||
|
|
319b2d06e6 | ||
|
|
2d2b3ade62 | ||
|
|
513946bdbf | ||
|
|
86ed0891e0 | ||
|
|
d8bf9422c2 | ||
|
|
a7169e8dad | ||
|
|
72f9fcbd64 | ||
|
|
d192ede484 | ||
|
|
da0c8bd0f1 | ||
|
|
5108330405 | ||
|
|
1c7e3c2ad2 | ||
|
|
2eb352c2d3 | ||
|
|
43bb4a36b4 | ||
|
|
c67abefc7b | ||
|
|
2ecb9036aa | ||
|
|
dbcfba993b | ||
|
|
ed22ce9421 | ||
|
|
8caa6d6a60 | ||
|
|
fb1af87048 | ||
|
+4 |
f73ca51f94 | ||
|
+4 |
ad839f92ac | ||
|
|
eb47df7172 |
No files matched your search
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
backend/**.db
|
||||
**.crt
|
||||
**.key
|
||||
backend/tmp
|
||||
backend/dist
|
||||
backend/backend*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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" ]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,4 +8,4 @@ userDefaults:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
highQuality: true
|
||||
audio: true
|
||||
+6
-2
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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, ¬used, &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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user