mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 13:12:00 -05:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a997d4605 | ||
|
|
2633768d9f | ||
|
|
4bca80a156 | ||
|
|
fc929970b3 | ||
|
|
12c71d38a9 | ||
|
|
641e4c5397 | ||
|
|
587cd62da6 | ||
|
|
ea19302c25 | ||
|
|
d4dd0c70f4 | ||
|
|
1babfa03bc | ||
|
|
daa48c3c31 | ||
|
|
a9e4fc27b0 | ||
|
|
1715577d99 | ||
|
|
5eb4dc7f7c | ||
|
|
4248849bb6 | ||
|
|
687dc01293 | ||
|
|
bfde65a41e | ||
|
|
c5eeb308fa | ||
|
|
12cd81340e | ||
|
|
2b532c368d | ||
|
|
28e9b81e43 | ||
|
|
6d4bffd407 | ||
|
|
5e49e1ecdd | ||
|
|
1aba10b87f | ||
|
|
15f484fba5 | ||
|
|
d4eb051294 | ||
|
|
1802e12811 | ||
|
|
5597b28f10 | ||
|
|
0f215333ee | ||
|
|
6d81d1973c | ||
|
|
869b640a99 | ||
|
|
3b8d179774 | ||
|
|
569314cdf4 | ||
|
|
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 |
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,8 +20,10 @@ frontend/tsconfig.tsbuildinfo
|
||||
/frontend/test-results
|
||||
/frontend/playwright-files
|
||||
/frontend/screenshots
|
||||
/frontend/generated
|
||||
/frontend/tests/playwright-files/demo*
|
||||
/frontend/loginAuth.json
|
||||
/frontend/sharePrepStorage.json
|
||||
/backend/vendor
|
||||
/backend/*.cov
|
||||
/backend/*test_config.yaml
|
||||
|
||||
+345
@@ -2,6 +2,351 @@
|
||||
|
||||
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.4.2
|
||||
|
||||
**Security**:
|
||||
- [Moderate] Add Rate Limiting on Authentication Endpoint Enables Brute Force Attacks (GHSA-r4v7-6wcg-ghj5)
|
||||
- [Critical] Path traversal in public share PATCH allows file ops outside shared directory -- thanks @Revanth011 and @fg0x0 (GHSA-qqqm-5547-774x)
|
||||
|
||||
**New Features**:
|
||||
- read-only source configuration via `source.config.readOnly: true` (#2438)
|
||||
|
||||
**Notes**:
|
||||
- auth rate limiting can be disabled via `auth.disableRateLimit`
|
||||
- updated share hash middleware (#2443)
|
||||
- updated source info popup to include private and readOnly properties
|
||||
|
||||
**BugFixes**:
|
||||
- Logout from share page now redirects to the share instead of `/Login` again. (#2245)
|
||||
- `This location cannot be reached` error when navigating with FileTree in shares. (#2245)
|
||||
- Fix FileTree rename and move actions in previews. (#2245)
|
||||
- Delete prompt not showing date and thumbnails in some previews. (#2245)
|
||||
- fix path slash issue on windows (#2451) (#2433) (#2419)
|
||||
- Always force url rewrite for onlyoffice internal URL. Fixes Error saving with OnlyOffice (#2450)
|
||||
- Overriding a Deny with an Allow not working (#2405)
|
||||
|
||||
## v1.4.1
|
||||
|
||||
**Security**:
|
||||
- Fix critical: unauthenticated user can view source info
|
||||
|
||||
## v1.4.0
|
||||
|
||||
**New Features**:
|
||||
- Add Passkey (WebAuthn) support for passwordless authentication (#2287) (#2379) -- thanks @juansoler
|
||||
- Lyrics support for audio files (#2360):
|
||||
- Updated UI for audio previews!
|
||||
- The lyrics and the player gets syncronized in UI with the audio player.
|
||||
- Supports embedded lyrics (ID3v2 USLT, flac, ogg, clyr) and sidecar `.lrc` files with the same name as the audio file.
|
||||
- The `Q` shortcut in audio files will toggle the new panel visibility.
|
||||
- `E` shortcut to change between tabs of the panel if it's open.
|
||||
- Api log filtering support via `apiFilter` option for logging.
|
||||
- regex filter that excludes matching full api paths from being logged. (eg. `/user\?id\=self`) Defaults to `^/health|^/favicon.ico|^/static|^/public/static`
|
||||
- Add config option to disable /health endpoint logging (#2291)
|
||||
- More shortcuts (#2300)
|
||||
- `CTRL+F1` switch to normal view.
|
||||
- `CTRL+F2` switch to gallery view.
|
||||
- `CTRL+F3` switch to list view.
|
||||
- `Double click` on empty space to select all items.
|
||||
- Option to hide certain files in UI by extension (#2403) (#2277)
|
||||
- Additional search features
|
||||
- wildcard search similar to regex, but limited to sqlite GLOB syntax (#769)
|
||||
- advanced search tool that shows all results in a listing thats sortable and actionable like a normal listing (#1051)
|
||||
- advanced search supports multiple custom scopes per search
|
||||
- multiple search terms with AND or OR logic support
|
||||
|
||||
**Notes**:
|
||||
- Enhanced indexing scheduler which doesn't wake the disk as often.
|
||||
- New API route `media/lyrics` used to fetch and parse lyrics (embedded or from `.lrc` sidecar) (#2360)
|
||||
- Swiping down gesture in fullscreen videos exit fullscreen instead of close preview.
|
||||
- Improved styles for path selection and tables
|
||||
- Improved style of drag and drop into listing view (#2407)
|
||||
- Edit Sidebar links has new "show tools in sidebar" toggle and all users have this enabled by default. can be disabled via for new users `userDefaults.sidebar.showTools: false`
|
||||
- Update user defaults ordering (#1140)
|
||||
- Save view modes and sizes into local storage instead of db (#2301)
|
||||
|
||||
**BugFixes**:
|
||||
- Blue overlay when using gestures in video files on mobile (#2360)
|
||||
- Playback queue wasn't updating when changing of folder (#2360)
|
||||
- Added missing `UserDefaults.listing.showCopyPath` option to config file (#2364)
|
||||
- Delete prompt thumbnail didn't worked when using it with the `delete` shortcut. (#2407)
|
||||
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
|
||||
- Drag and drop style state was stuck until page reload (#2407)
|
||||
- Navigate close settings shows "something went wrong" (#2047)
|
||||
|
||||
## v1.3.10
|
||||
|
||||
**Notes**:
|
||||
- When indexing is disabled for a source, the usage will always reported as partition size.
|
||||
- removed deprecated `source.config.disableIndexing`, see [rules](https://filebrowserquantum.com/en/docs/advanced/source-configuration/conditional-rules/#disable-indexing)
|
||||
|
||||
**BugFixes**:
|
||||
- Disable index option not working (#2385)
|
||||
|
||||
## v1.3.9
|
||||
|
||||
**Security**:
|
||||
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
|
||||
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
|
||||
|
||||
**BugFixes**:
|
||||
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
|
||||
|
||||
## v1.3.8
|
||||
|
||||
**BugFixes**:
|
||||
- Quick download icon style after icon change.
|
||||
- Missing error popup for resource creatoin actions (upload/create)
|
||||
- EnforcedOtp login failure until restart (#2330)
|
||||
- Thumbnails for Folders only display sporadically (#2353)
|
||||
- Unwanted user scope change for users with non-default scopes (#2347)
|
||||
- Fix sidebar source info totals (#2321) (#2322) (#982)
|
||||
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
|
||||
|
||||
## v1.3.7
|
||||
|
||||
**Notes**:
|
||||
- creating/deleting password-based user requires reauthentication (#2112)
|
||||
|
||||
**BugFixes**:
|
||||
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
|
||||
- No Loginfields shown if password authentification is set to false (#2331)
|
||||
|
||||
## v1.3.6
|
||||
|
||||
**BugFixes**:
|
||||
- file watcher not working for files and not respecting line count (#2314)
|
||||
- "source" type link shows up multiple times when adding sidebar links if user has multiple sources.
|
||||
- JWT ES256 results in error (#2188)
|
||||
- single file shares not loading editor text content (#2122)
|
||||
|
||||
## v1.3.5
|
||||
|
||||
**Notes**:
|
||||
- any password change or admin user property change for a password user requires reauthentication (#2112)
|
||||
|
||||
**BugFixes**:
|
||||
- PWA icon fixes (#2292)
|
||||
- deny-rule'd folders visible in directory listings (regression from v1.2.4-stable) (#2295)
|
||||
- OTP and password requirement fixes (#2112) (#2263)
|
||||
- CLI not picking up config properly
|
||||
- Storage usage numbers on first load adjusted.
|
||||
- Some items in userDefaults are not setting defaults for new users properly #2278
|
||||
- Fix copy to clipboard and simplify code (#2281) (#2274)
|
||||
- Fix search shortcut `/` and pdf previews (#2307)
|
||||
- Special permissions bit not set when declared in createDirectoryPermission (#2283)
|
||||
- Execute permission altered after editing sh script with FB Quantum (#2309)
|
||||
- Source is red but working (#2289)
|
||||
|
||||
## v1.3.4
|
||||
|
||||
**New Features**:
|
||||
- Epub placementg url anchors to bookmark a specific location on the doc.
|
||||
- Add Requirement for Current Password When Changing Account Password (#2112)
|
||||
- Copy file path to clipborad through right click (#2204)
|
||||
|
||||
**Notes**:
|
||||
- user scope editing has path picker and filesystem validation
|
||||
|
||||
**BugFixes**:
|
||||
- Swiping media files doesn't respect Playback Queue (#2243)
|
||||
- Audio metadata and album thumbnails doesn't work (#2236)
|
||||
- Search spinner wouldn't show up sometimes while searching
|
||||
- The default landing page source should always be the first source in the sidebar, that wasn't always respected.
|
||||
|
||||
## v1.3.3
|
||||
|
||||
**New Features**:
|
||||
- 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**:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
|
||||
[](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
|
||||
[](https://app.codacy.com/gh/gtsteffaniak/filebrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://hub.docker.com/r/gtstef/filebrowser)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
@@ -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,47 +94,45 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
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 | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
|
||||
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
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: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
*Accessing files from FileBrowser via WebDav is a newly introduced and experimental feature.
|
||||
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can browse archive files | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Can convert documents | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
Can convert videos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Can convert photos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
+2
-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
|
||||
@@ -44,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,8 @@ 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/showme.txt -r user -v admin -allow -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p /denied -r user -v admin -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -3,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
|
||||
@@ -36,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
|
||||
@@ -2,7 +2,8 @@ server-main: &server-main
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
@@ -11,5 +12,7 @@ server-main: &server-main
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
disableIndexing: true
|
||||
defaultEnabled: true
|
||||
defaultEnabled: true
|
||||
rules:
|
||||
- folderPath: "/"
|
||||
viewable: true
|
||||
@@ -0,0 +1,49 @@
|
||||
server:
|
||||
port: 8080
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
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
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -2,7 +2,8 @@ server:
|
||||
port: 80
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "exclude"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -2,7 +2,8 @@ server:
|
||||
port: 8080
|
||||
baseURL: "/subpath"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
|
||||
@@ -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,11 @@
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -2,7 +2,8 @@ server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
@@ -11,8 +12,10 @@ server:
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
disableIndexing: true
|
||||
defaultEnabled: true
|
||||
rules:
|
||||
- folderPath: "/"
|
||||
viewable: true
|
||||
- path: "/tests/playwright-files"
|
||||
name: "access"
|
||||
config:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,8 @@ server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright + files"
|
||||
|
||||
@@ -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
|
||||
@@ -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 executeScan)
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+316
-107
@@ -1,11 +1,13 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -25,6 +27,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, false, 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
|
||||
@@ -36,11 +71,8 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
metadataCount++
|
||||
}
|
||||
}
|
||||
// Process files concurrently using goroutines
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // Protects processedCount
|
||||
|
||||
// Refresh directory to update folder sizes
|
||||
wg.Go(func() {
|
||||
indexPath := idx.MakeIndexPath(response.Path, true)
|
||||
err := idx.RefreshDirectory(indexPath, false)
|
||||
@@ -49,54 +81,9 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
}
|
||||
})
|
||||
|
||||
// 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 {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
addMetadataToChildren(response, opts, &wg)
|
||||
}
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -116,35 +103,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
|
||||
@@ -152,49 +145,157 @@ 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 indexPath, "", errors.ErrAccessDenied
|
||||
}
|
||||
return indexPath, userScope, nil
|
||||
}
|
||||
|
||||
type Items struct {
|
||||
Files []string `json:"files,omitempty"`
|
||||
Folders []string `json:"folders,omitempty"`
|
||||
}
|
||||
|
||||
// This removes files whose names match any extension in hideFileExt.
|
||||
func filterFilesByExt(files []iteminfo.ExtendedItemInfo, hideFileExt string) []iteminfo.ExtendedItemInfo {
|
||||
if hideFileExt == "" {
|
||||
return files
|
||||
}
|
||||
filtered := files[:0]
|
||||
for _, f := range files {
|
||||
if !utils.HideFileByExt(f.Name, hideFileExt) {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
|
||||
items := Items{}
|
||||
indexPath, _, topLevelErr := CheckPermissions(opts, access, user)
|
||||
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
|
||||
if topLevelErr != nil && !accessRulesErr {
|
||||
return items, topLevelErr
|
||||
}
|
||||
// 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,
|
||||
HideFileExt: opts.HideFileExt,
|
||||
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
|
||||
}
|
||||
hasAccessibleItems := len(info.Files) > 0 || len(info.Folders) > 0
|
||||
if accessRulesErr && !hasAccessibleItems {
|
||||
return items, errors.ErrAccessDenied
|
||||
}
|
||||
if opts.Only == "files" || opts.Only == "" {
|
||||
for _, file := range info.Files {
|
||||
if opts.HideFileExt != "" && utils.HideFileByExt(file.Name, opts.HideFileExt) {
|
||||
continue
|
||||
}
|
||||
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, topLevelErr := CheckPermissions(opts, access, user)
|
||||
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
|
||||
if topLevelErr != nil && !accessRulesErr {
|
||||
return response, topLevelErr
|
||||
}
|
||||
// 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,
|
||||
HideFileExt: opts.HideFileExt,
|
||||
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
|
||||
}
|
||||
}
|
||||
hasAccessibleItems := len(info.Files) > 0 || len(info.Folders) > 0
|
||||
if accessRulesErr && !hasAccessibleItems {
|
||||
return response, errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if info.Type == "directory" && opts.HideFileExt != "" {
|
||||
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Process directory metadata if requested
|
||||
if info.Type == "directory" {
|
||||
processDirectoryMetadata(response, idx, opts)
|
||||
if opts.HideFileExt != "" {
|
||||
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -205,9 +306,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,
|
||||
}
|
||||
@@ -218,7 +323,6 @@ 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 {
|
||||
@@ -228,22 +332,20 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
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,
|
||||
}
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, false, nil)
|
||||
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.HasMetadata = true
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
}
|
||||
return
|
||||
@@ -273,10 +375,58 @@ func generateOfficeId(realPath string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
// Parses raw LRC text into a slice of Lyric.
|
||||
// Lines with [hh:mm:ss.xx] (timestamps) will be parsed to ms for synced lyrics
|
||||
// The ones without timestamp are kept as unsynced (timestamp 0).
|
||||
func parseLRC(raw string) []iteminfo.Lyric {
|
||||
var lines []iteminfo.Lyric
|
||||
scanner := bufio.NewScanner(strings.NewReader(raw))
|
||||
re := regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
matches := re.FindStringSubmatch(line)
|
||||
if len(matches) == 6 {
|
||||
// Hours default to 0 (if not present in the timestamps)
|
||||
h := 0
|
||||
if matches[1] != "" {
|
||||
h, _ = strconv.Atoi(matches[1])
|
||||
}
|
||||
min, _ := strconv.Atoi(matches[2])
|
||||
sec, _ := strconv.Atoi(matches[3])
|
||||
|
||||
msStr := matches[4]
|
||||
// Normalise to 3 digits, some songs have more than 3 digits in the milliseconds
|
||||
switch {
|
||||
case len(msStr) < 3:
|
||||
msStr += strings.Repeat("0", 3-len(msStr))
|
||||
case len(msStr) > 3:
|
||||
msStr = msStr[:3]
|
||||
}
|
||||
ms, _ := strconv.Atoi(msStr)
|
||||
timestamp := int64((h*3600+min*60+sec)*1000 + ms)
|
||||
text := strings.TrimSpace(matches[5])
|
||||
lines = append(lines, iteminfo.Lyric{
|
||||
Text: text,
|
||||
Timestamp: timestamp,
|
||||
})
|
||||
} else {
|
||||
// Line without a valid timestamp – treat as unsynced (timestamp 0)
|
||||
lines = append(lines, iteminfo.Lyric{
|
||||
Text: line,
|
||||
Timestamp: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
|
||||
// and optionally extracts duration using the ffmpeg service with concurrency control
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -312,6 +462,30 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
track, _ := m.Track()
|
||||
item.Metadata.Track = track
|
||||
|
||||
// Check if lyrics are available without extract them
|
||||
if rawLyrics := m.Lyrics(); rawLyrics != "" {
|
||||
item.Metadata.HasLyrics = true
|
||||
} else {
|
||||
// Check for sidecar .lrc file
|
||||
dir := filepath.Dir(realPath)
|
||||
base := filepath.Base(realPath)
|
||||
ext := filepath.Ext(base)
|
||||
nameWithoutExt := strings.TrimSuffix(base, ext)
|
||||
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
|
||||
if _, err := os.Stat(lrcPath); err == nil {
|
||||
item.Metadata.HasLyrics = true
|
||||
}
|
||||
}
|
||||
|
||||
if getLyrics {
|
||||
lyrics, err := ExtractLyrics(realPath)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract lyrics for %s: %v", realPath, err)
|
||||
} else if len(lyrics) > 0 {
|
||||
item.Metadata.Lyrics = lyrics
|
||||
}
|
||||
}
|
||||
|
||||
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
|
||||
// This respects concurrency limits and gracefully handles missing ffmpeg
|
||||
if getDuration {
|
||||
@@ -344,6 +518,37 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns lyrics from an audio file (from embedded tags or from a .lrc file with the same name).
|
||||
func ExtractLyrics(realPath string) ([]iteminfo.Lyric, error) {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
m, err := tag.ReadFrom(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lyrics []iteminfo.Lyric
|
||||
if raw := m.Lyrics(); raw != "" {
|
||||
lyrics = parseLRC(raw)
|
||||
}
|
||||
if len(lyrics) == 0 {
|
||||
// Check for sidecar .lrc file
|
||||
dir := filepath.Dir(realPath)
|
||||
base := filepath.Base(realPath)
|
||||
ext := filepath.Ext(base)
|
||||
nameWithoutExt := strings.TrimSuffix(base, ext)
|
||||
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
|
||||
if data, err := os.ReadFile(lrcPath); err == nil {
|
||||
lyrics = parseLRC(string(data))
|
||||
}
|
||||
}
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// extractVideoMetadata extracts duration from video files using ffprobe
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
@@ -372,8 +577,13 @@ 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 {
|
||||
if !index.Config.ResolvedRules.IndexingDisabled {
|
||||
indexPath := index.MakeIndexPath(absPath, isDir)
|
||||
|
||||
// Perform the physical deletion
|
||||
@@ -410,7 +620,7 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
if idx == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
if idx.Config.DisableIndexing {
|
||||
if idx.Config.ResolvedRules.IndexingDisabled {
|
||||
return nil
|
||||
}
|
||||
// Always normalize path using MakeIndexPath
|
||||
@@ -518,7 +728,7 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !srcIdx.Config.DisableIndexing {
|
||||
if !srcIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
go func() {
|
||||
if isSrcDir {
|
||||
srcIdx.DeleteMetadata(srcIndexPath, true, true)
|
||||
@@ -533,7 +743,7 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
|
||||
// Handle DESTINATION indexing
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !dstIdx.Config.DisableIndexing {
|
||||
if !dstIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// Recursively index the moved directory tree
|
||||
@@ -591,7 +801,7 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
|
||||
// Refresh source (non-recursive, just metadata)
|
||||
srcIdx := indexing.GetIndex(sourceIndex)
|
||||
if srcIdx != nil && !srcIdx.Config.DisableIndexing {
|
||||
if srcIdx != nil && !srcIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
srcRefreshPath := realsrc
|
||||
if !isSrcDir {
|
||||
srcRefreshPath = filepath.Dir(realsrc)
|
||||
@@ -602,7 +812,7 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
// Refresh destination (RECURSIVE for directories to capture full tree)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
dstIdx := indexing.GetIndex(destIndex)
|
||||
if dstIdx != nil && !dstIdx.Config.DisableIndexing {
|
||||
if dstIdx != nil && !dstIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// Recursively index the copied directory tree
|
||||
@@ -689,22 +899,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
|
||||
@@ -717,11 +926,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
|
||||
|
||||
@@ -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,12 @@ 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",
|
||||
".lrc": "text/lyrics",
|
||||
".sbv": "text/plain",
|
||||
".ssa": "text/plain",
|
||||
".ssi": "text/x-server-parsed-html",
|
||||
".ssm": "application/streamingmedia",
|
||||
".sst": "application/vndms-pkicertstore",
|
||||
@@ -597,11 +599,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,56 +19,43 @@ 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)
|
||||
}
|
||||
for i, scope := range u.Scopes {
|
||||
source, ok := settings.Config.Server.SourceMap[scope.Name]
|
||||
info, ok := users.ResolveSourceKey(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
|
||||
source, ok := settings.Config.Server.SourceMap[info.Path]
|
||||
if !ok {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -204,3 +240,22 @@ func ClearCacheDir(cacheDir string) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ClearDirectoryContents removes all files and subdirectories inside dir; dir itself remains.
|
||||
// If dir does not exist, returns nil.
|
||||
func ClearDirectoryContents(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -146,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")
|
||||
@@ -173,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,
|
||||
@@ -198,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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
type webAuthnSession struct {
|
||||
SessionData *webauthn.SessionData
|
||||
UserID uint
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type WebAuthnService struct {
|
||||
wa *webauthn.WebAuthn
|
||||
sessions sync.Map
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
var webAuthnInstance *WebAuthnService
|
||||
|
||||
func InitWebAuthn() error {
|
||||
cfg := &settings.Config.Auth.Methods.PasskeyAuth
|
||||
if !cfg.Enabled {
|
||||
webAuthnInstance = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
waCfg, err := deriveWebAuthnConfig(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webauthn config derivation failed: %w", err)
|
||||
}
|
||||
|
||||
wa, err := webauthn.New(waCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize WebAuthn: %w", err)
|
||||
}
|
||||
|
||||
webAuthnInstance = &WebAuthnService{
|
||||
wa: wa,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go webAuthnInstance.cleanupExpiredSessions()
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetWebAuthn() *WebAuthnService {
|
||||
return webAuthnInstance
|
||||
}
|
||||
|
||||
func IsWebAuthnEnabled() bool {
|
||||
return webAuthnInstance != nil
|
||||
}
|
||||
|
||||
// ValidateOrigin checks if the given origin is in the configured whitelist.
|
||||
// Returns true if the origin is allowed, false otherwise.
|
||||
func (s *WebAuthnService) ValidateOrigin(origin string) bool {
|
||||
for _, allowedOrigin := range s.wa.Config.RPOrigins {
|
||||
if allowedOrigin == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func generateSessionID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return hex.EncodeToString([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) BeginMFALogin(username, password string, rpID string, userStore *users.Storage) (sessionID string, assertion *protocol.CredentialAssertion, err error) {
|
||||
user, getErr := userStore.Get(username)
|
||||
passwordHash := utils.InvalidPasswordHash
|
||||
if getErr == nil {
|
||||
passwordHash = user.Password
|
||||
}
|
||||
// Always run password check to prevent timing-based username enumeration
|
||||
if err = utils.CheckPwd(password, passwordHash); err != nil {
|
||||
return "", nil, errors.ErrUnauthorized
|
||||
}
|
||||
if getErr != nil {
|
||||
return "", nil, errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
if !user.HasPasskeyMFA() {
|
||||
return "", nil, errors.ErrPasskeyNoCredential
|
||||
}
|
||||
|
||||
var opts []webauthn.LoginOption
|
||||
if rpID != "" {
|
||||
opts = append(opts, webauthn.WithLoginRelyingPartyID(rpID))
|
||||
}
|
||||
assertion, sessionData, err := s.wa.BeginLogin(user, opts...)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to begin WebAuthn login: %w", err)
|
||||
}
|
||||
|
||||
sessionID = generateSessionID()
|
||||
s.sessions.Store(sessionID, &webAuthnSession{
|
||||
SessionData: sessionData,
|
||||
UserID: user.ID,
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
|
||||
return sessionID, assertion, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) FinishMFALogin(sessionID string, r *http.Request, userStore *users.Storage) (*users.User, error) {
|
||||
sessionVal, ok := s.sessions.Load(sessionID)
|
||||
if !ok {
|
||||
return nil, errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
sess, ok := sessionVal.(*webAuthnSession)
|
||||
if !ok {
|
||||
return nil, errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
defer s.sessions.Delete(sessionID)
|
||||
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
return nil, errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
|
||||
user, err := userStore.Get(sess.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
credential, err := s.wa.FinishLogin(user, *sess.SessionData, r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", errors.ErrPasskeyVerification, err)
|
||||
}
|
||||
|
||||
credB64 := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||
for i := range user.PasskeyCredentials {
|
||||
if user.PasskeyCredentials[i].ID == credB64 {
|
||||
users.UpdateCredentialFromLibrary(&user.PasskeyCredentials[i], credential)
|
||||
if err := userStore.Update(user, false, "PasskeyCredentials"); err != nil {
|
||||
return nil, fmt.Errorf("failed to update credential: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) BeginRegistration(user *users.User, rpID string) (sessionID string, creation *protocol.CredentialCreation, err error) {
|
||||
var opts []webauthn.RegistrationOption
|
||||
if rpID != "" {
|
||||
opts = append(opts, webauthn.WithRegistrationRelyingPartyID(rpID))
|
||||
}
|
||||
creation, sessionData, err := s.wa.BeginRegistration(user, opts...)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
|
||||
}
|
||||
|
||||
sessionID = generateSessionID()
|
||||
s.sessions.Store(sessionID, &webAuthnSession{
|
||||
SessionData: sessionData,
|
||||
UserID: user.ID,
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
|
||||
return sessionID, creation, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) FinishRegistration(user *users.User, sessionID string, credentialName string, r *http.Request) error {
|
||||
sessionVal, ok := s.sessions.Load(sessionID)
|
||||
if !ok {
|
||||
return errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
sess, ok := sessionVal.(*webAuthnSession)
|
||||
if !ok {
|
||||
return errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
defer s.sessions.Delete(sessionID)
|
||||
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
return errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
|
||||
if sess.UserID != user.ID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
credential, err := s.wa.FinishRegistration(user, *sess.SessionData, r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to finish WebAuthn registration: %w", err)
|
||||
}
|
||||
|
||||
credB64 := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||
for _, c := range user.PasskeyCredentials {
|
||||
if c.ID == credB64 {
|
||||
return errors.ErrPasskeyExists
|
||||
}
|
||||
}
|
||||
|
||||
newCred := users.CredentialFromLibrary(credentialName, credential)
|
||||
user.PasskeyCredentials = append(user.PasskeyCredentials, newCred)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) DeleteCredential(user *users.User, credentialID string) error {
|
||||
for i, c := range user.PasskeyCredentials {
|
||||
if c.ID == credentialID {
|
||||
user.PasskeyCredentials = append(user.PasskeyCredentials[:i], user.PasskeyCredentials[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.ErrPasskeyNoCredential
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) cleanupExpiredSessions() {
|
||||
ticker := time.NewTicker(2 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
now := time.Now()
|
||||
s.sessions.Range(func(key, value interface{}) bool {
|
||||
sess, ok := value.(*webAuthnSession)
|
||||
if !ok {
|
||||
s.sessions.Delete(key)
|
||||
return true
|
||||
}
|
||||
if now.After(sess.ExpiresAt) {
|
||||
s.sessions.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deriveWebAuthnConfig(cfg *settings.PasskeyAuthConfig) (*webauthn.Config, error) {
|
||||
rpID := cfg.RPID
|
||||
if rpID == "" {
|
||||
if settings.Config.Server.ExternalUrl != "" {
|
||||
if u, err := url.Parse(settings.Config.Server.ExternalUrl); err == nil {
|
||||
rpID = u.Hostname()
|
||||
}
|
||||
}
|
||||
if rpID == "" {
|
||||
rpID = "localhost"
|
||||
}
|
||||
}
|
||||
|
||||
displayName := cfg.RPDisplayName
|
||||
if displayName == "" {
|
||||
displayName = settings.Config.Frontend.Name
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = "FileBrowser Quantum"
|
||||
}
|
||||
|
||||
origins := cfg.RPOrigins
|
||||
if len(origins) == 0 {
|
||||
if settings.Config.Server.ExternalUrl != "" {
|
||||
origins = append(origins, settings.Config.Server.ExternalUrl)
|
||||
}
|
||||
if settings.Config.Server.InternalUrl != "" && settings.Config.Server.InternalUrl != settings.Config.Server.ExternalUrl {
|
||||
origins = append(origins, settings.Config.Server.InternalUrl)
|
||||
}
|
||||
if len(origins) == 0 {
|
||||
origins = append(origins, "http://localhost")
|
||||
}
|
||||
}
|
||||
|
||||
return &webauthn.Config{
|
||||
RPID: rpID,
|
||||
RPDisplayName: displayName,
|
||||
RPOrigins: origins,
|
||||
AttestationPreference: protocol.PreferNoAttestation,
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
UserVerification: protocol.VerificationPreferred,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
+78
-133
@@ -23,7 +23,7 @@ var (
|
||||
)
|
||||
|
||||
// return bool to indicate if the program should continue running
|
||||
func runCLI() bool {
|
||||
func runCLI() (bool, bool) {
|
||||
|
||||
generateYaml()
|
||||
|
||||
@@ -53,85 +53,101 @@ 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]
|
||||
|
||||
// Handle old syntax: "set -u user,pass -c config.yaml"
|
||||
if subcommand == "-u" {
|
||||
// Parse flags starting from os.Args[2:] to include the -u flag
|
||||
err := setCmd.Parse(os.Args[2:])
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
|
||||
err = setUser(dbConfig, asAdmin)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// New pattern: subcommand-based (e.g., "set user" or "set rule")
|
||||
// Parse flags starting from os.Args[3:] to skip the subcommand
|
||||
err := setCmd.Parse(os.Args[3:])
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
switch subcommand {
|
||||
case "rule":
|
||||
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
default:
|
||||
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", subcommand)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
case "version":
|
||||
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
|
||||
Version : %v
|
||||
Commit : %v
|
||||
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
|
||||
`, version.Version, version.CommitSHA, version.Version)
|
||||
return false
|
||||
`, 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
|
||||
|
||||
switch cliSubcmd {
|
||||
case "setup":
|
||||
createConfig(configPath)
|
||||
return false, false
|
||||
case "set":
|
||||
if setKind == "-u" {
|
||||
dbExists := getStore(storeCfg)
|
||||
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":
|
||||
dbExists := getStore(storeCfg)
|
||||
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, false
|
||||
default:
|
||||
dbExists := getStore(storeCfg)
|
||||
return true, dbExists
|
||||
}
|
||||
}
|
||||
|
||||
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
|
||||
@@ -148,10 +164,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 {
|
||||
@@ -202,7 +214,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{
|
||||
@@ -215,7 +226,7 @@ func setUser(dbConfig string, asAdmin bool) error {
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
if source.Config.DefaultEnabled {
|
||||
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
|
||||
Name: source.Name,
|
||||
Name: source.Path,
|
||||
Scope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
@@ -227,7 +238,7 @@ func setUser(dbConfig string, asAdmin bool) error {
|
||||
} else {
|
||||
logger.Infof("Creating non-admin user: %s\n", username)
|
||||
}
|
||||
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
|
||||
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Account.Permissions)
|
||||
newUser.Permissions.Admin = asAdmin
|
||||
err = storage.CreateUser(newUser, newUser.Permissions)
|
||||
if err != nil {
|
||||
@@ -248,7 +259,7 @@ func setUser(dbConfig string, asAdmin bool) error {
|
||||
}
|
||||
// Ensure version is set for existing users being updated
|
||||
if user.Version == 0 {
|
||||
user.Version = 1
|
||||
user.Version = users.CurrentUserMigrationVersion
|
||||
}
|
||||
err = store.Users.Save(user, true, false)
|
||||
if err != nil {
|
||||
@@ -258,51 +269,6 @@ func setUser(dbConfig string, asAdmin bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDefaults defines default settings for new users.
|
||||
type UserDefaults struct {
|
||||
Permissions users.Permissions `yaml:"permissions"`
|
||||
}
|
||||
|
||||
// Frontend defines settings related to the web interface.
|
||||
type Frontend struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
}
|
||||
|
||||
// Source defines a directory to be served.
|
||||
type Source struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
// Server defines server-specific configurations.
|
||||
type Server struct {
|
||||
Port int `yaml:"port"`
|
||||
Database string `yaml:"database"`
|
||||
Sources []Source `yaml:"sources"`
|
||||
Logging []LogConfig `json:"logging"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
|
||||
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
|
||||
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
|
||||
NoColors bool `json:"noColors"` // disable colors in the output
|
||||
Utc bool `json:"utc"` // use UTC time in the output instead of local time
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
AdminUsername string `yaml:"adminUsername"`
|
||||
AdminPassword string `yaml:"adminPassword"`
|
||||
}
|
||||
|
||||
// Settings is the top-level configuration structure.
|
||||
type Settings struct {
|
||||
Server Server `yaml:"server"`
|
||||
Frontend Frontend `yaml:"frontend,omitempty"`
|
||||
Auth Auth `yaml:"auth"`
|
||||
UserDefaults UserDefaults `yaml:"userDefaults"`
|
||||
}
|
||||
|
||||
// askQuestion displays a prompt and reads a line of input from the user.
|
||||
// It returns the defaultValue if the user's input is empty.
|
||||
func askQuestion(reader *bufio.Reader, prompt string, defaultValue string) string {
|
||||
@@ -334,30 +300,9 @@ func askYesNoQuestion(reader *bufio.Reader, prompt string, defaultValue string)
|
||||
|
||||
// createConfig orchestrates the configuration process by asking the user a series of questions.
|
||||
func createConfig(configpath string) {
|
||||
// check if config file exists
|
||||
if _, err := os.Stat("config.yaml"); err == nil {
|
||||
fmt.Println("Config file 'config.yaml' already exists, skipping setup.")
|
||||
return
|
||||
}
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
config := Settings{
|
||||
Server: Server{
|
||||
Logging: []LogConfig{
|
||||
{
|
||||
Levels: "info|warning|error",
|
||||
ApiLevels: "info|warning|error",
|
||||
Output: "stdout",
|
||||
NoColors: false,
|
||||
Utc: false,
|
||||
},
|
||||
},
|
||||
Sources: []Source{
|
||||
{
|
||||
Path: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
config := settings.SetDefaults(false)
|
||||
config.Server.Sources = []*settings.Source{{Path: "./"}}
|
||||
|
||||
fmt.Println("--- Starting Configuration Setup ---")
|
||||
realPath := ""
|
||||
@@ -424,8 +369,8 @@ func createConfig(configpath string) {
|
||||
config.Auth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
|
||||
|
||||
// 6. Ask boolean (Yes/No) questions using the helper
|
||||
config.UserDefaults.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
|
||||
config.UserDefaults.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
|
||||
config.UserDefaults.Account.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
|
||||
config.UserDefaults.Account.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
|
||||
|
||||
fmt.Println("--- Configuration Complete ---")
|
||||
|
||||
|
||||
+27
-10
@@ -12,6 +12,7 @@ import (
|
||||
_ "net/http/pprof"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
@@ -54,10 +55,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 != "" {
|
||||
@@ -76,11 +81,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")
|
||||
@@ -89,6 +89,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))
|
||||
@@ -172,6 +177,9 @@ func StartFilebrowser() {
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
<-shutdownComplete
|
||||
if err := fileutils.ClearDirectoryContents(settings.DownloadCacheDir()); err != nil {
|
||||
logger.Warningf("failed to clear download spool on shutdown: %v", err)
|
||||
}
|
||||
logger.Info("Shutdown complete.")
|
||||
}
|
||||
|
||||
@@ -182,7 +190,7 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
cacheDir := settings.Config.Server.CacheDir
|
||||
numWorkers := settings.Config.Server.NumImageProcessors
|
||||
ffmpeg.SetFFmpegPaths()
|
||||
|
||||
|
||||
// Initialize asset filesystem before starting services
|
||||
if settings.Env.EmbeddedFs {
|
||||
embeddedAssets := fbhttp.GetEmbeddedAssets()
|
||||
@@ -194,7 +202,7 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
} else {
|
||||
fileutils.InitAssetFS(nil, false)
|
||||
}
|
||||
|
||||
|
||||
// Start preview service
|
||||
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
|
||||
if err != nil {
|
||||
@@ -202,12 +210,21 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
}
|
||||
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()
|
||||
|
||||
// Initialize WebAuthn/Passkey service if enabled
|
||||
if err := auth.InitWebAuthn(); err != nil {
|
||||
logger.Fatalf("Failed to initialize WebAuthn: %v", err)
|
||||
}
|
||||
|
||||
fbhttp.StartHttp(ctx, store, shutdownComplete)
|
||||
return nil
|
||||
}
|
||||
+67
-32
@@ -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,15 @@ func validateUserInfo(newDB bool) {
|
||||
if updateSidebarLinks(user) {
|
||||
updateUser = true
|
||||
}
|
||||
if updateTokens(user) {
|
||||
updateUser = true
|
||||
}
|
||||
if updateShowToolsInSidebar(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 +65,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", "ShowToolsInSidebar"}
|
||||
if changePass {
|
||||
fields = append(fields, "Password")
|
||||
}
|
||||
err := store.Users.Update(user, true, fields...)
|
||||
if err != nil {
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
}
|
||||
@@ -71,10 +81,12 @@ func updateUserScopes(user *users.User) bool {
|
||||
newScopes := []users.SourceScope{}
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
// Build map for existing scopes by Name
|
||||
// Build map of existing scopes keyed by canonical source path (DB may use path or display name).
|
||||
existing := make(map[string]users.SourceScope)
|
||||
for _, s := range user.Scopes {
|
||||
existing[s.Name] = s
|
||||
if info, ok := users.ResolveSourceKey(s.Name); ok {
|
||||
existing[info.Path] = s
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve order by using Config.Server.Sources
|
||||
@@ -90,7 +102,6 @@ func updateUserScopes(user *users.User) bool {
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: src.Path,
|
||||
Scope: existingScope.Scope,
|
||||
@@ -98,17 +109,30 @@ func updateUserScopes(user *users.User) bool {
|
||||
seen[src.Path] = struct{}{}
|
||||
}
|
||||
|
||||
// Preserve user-defined scopes not matching current sources, append to end
|
||||
// Preserve explicit or legacy scopes not already merged (e.g. non-defaultEnabled sources, removed sources).
|
||||
for _, s := range user.Scopes {
|
||||
if _, ok := seen[s.Name]; !ok {
|
||||
newScopes = append(newScopes, s)
|
||||
if info, ok := users.ResolveSourceKey(s.Name); ok {
|
||||
if _, already := seen[info.Path]; already {
|
||||
continue
|
||||
}
|
||||
}
|
||||
newScopes = append(newScopes, s)
|
||||
}
|
||||
changed := !reflect.DeepEqual(user.Scopes, newScopes)
|
||||
user.Scopes = newScopes
|
||||
return changed
|
||||
}
|
||||
|
||||
// updateShowToolsInSidebar one-time defaults for legacy users (Version < CurrentUserMigrationVersion) from configured userDefaults.
|
||||
func updateShowToolsInSidebar(user *users.User) bool {
|
||||
if user.Version >= 3 {
|
||||
return false
|
||||
}
|
||||
user.ShowToolsInSidebar = true
|
||||
user.Version = users.CurrentUserMigrationVersion
|
||||
return true
|
||||
}
|
||||
|
||||
func updateShowFirstLogin(user *users.User) bool {
|
||||
if user.ShowFirstLogin && !settings.Env.IsFirstLoad {
|
||||
user.ShowFirstLogin = false
|
||||
@@ -119,11 +143,10 @@ 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
|
||||
user.Permissions.Download = true
|
||||
// if any keys are true, set the permissions to true
|
||||
if user.Perm.Api {
|
||||
user.Permissions.Api = true
|
||||
@@ -165,7 +188,7 @@ func updatePermissions(user *users.User) bool {
|
||||
user.Permissions.Delete = true
|
||||
updateUser = true
|
||||
}
|
||||
user.Version = 1
|
||||
user.Version = users.CurrentUserMigrationVersion
|
||||
if updateUser {
|
||||
createBackup = true
|
||||
}
|
||||
@@ -197,13 +220,13 @@ 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 != "" {
|
||||
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
|
||||
validSourceLinksCount++
|
||||
}
|
||||
if link.SourceName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := users.ResolveSourceKey(link.SourceName); ok {
|
||||
validSourceLinksCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,34 +238,46 @@ func updateSidebarLinks(user *users.User) bool {
|
||||
|
||||
// If user has source links but NONE are valid, rebuild from their scopes
|
||||
if validSourceLinksCount == 0 {
|
||||
logger.Infof("User %s has %d stale source links, rebuilding from scopes", user.Username, sourceLinksCount)
|
||||
|
||||
// Remove all existing source links
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if link.Category != "source" {
|
||||
if !strings.HasPrefix(link.Category, "source") {
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new source links based on user's scopes
|
||||
for _, scope := range user.Scopes {
|
||||
if source, ok := settings.Config.Server.SourceMap[scope.Name]; ok {
|
||||
// User has access to this source, add it to sidebar
|
||||
newLinks = append(newLinks, users.SidebarLink{
|
||||
Name: source.Name,
|
||||
Category: "source",
|
||||
Target: "/",
|
||||
Icon: "",
|
||||
SourceName: source.Path,
|
||||
})
|
||||
info, ok := users.ResolveSourceKey(scope.Name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
newLinks = append(newLinks, users.SidebarLink{
|
||||
Name: info.Name,
|
||||
Category: "source",
|
||||
Target: "/",
|
||||
Icon: "",
|
||||
SourceName: info.Path,
|
||||
})
|
||||
}
|
||||
|
||||
user.SidebarLinks = newLinks
|
||||
return true
|
||||
}
|
||||
|
||||
// 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 = users.CurrentUserMigrationVersion
|
||||
return true
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
"sourceA": &sourceA,
|
||||
"sourceB": &sourceB,
|
||||
}
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
// ---------------------
|
||||
// Test Cases Definition
|
||||
@@ -156,7 +157,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,10 +19,17 @@ 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")
|
||||
ErrPasskeyMFARequired = errors.New("passkey MFA is required")
|
||||
ErrPasskeyVerification = errors.New("passkey verification failed")
|
||||
ErrPasskeyNoCredential = errors.New("no passkey credentials found for user")
|
||||
ErrPasskeyExists = errors.New("passkey credential already exists")
|
||||
ErrPasskeyNotEnabled = errors.New("passkey authentication is not enabled")
|
||||
ErrPasskeyInvalidSession = errors.New("invalid or expired passkey session")
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
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{
|
||||
Sidebar: UserDefaultsSidebar{
|
||||
DisableQuickToggles: true,
|
||||
HideFileActions: true,
|
||||
DisableHideOnPreview: true,
|
||||
Sticky: false,
|
||||
HideFiles: true,
|
||||
ShowTools: boolPtr(true),
|
||||
},
|
||||
Listing: UserDefaultsListing{
|
||||
DeleteWithoutConfirming: true,
|
||||
DateFormat: true,
|
||||
ShowHidden: true,
|
||||
QuickDownload: true,
|
||||
ShowSelectMultiple: true,
|
||||
SingleClick: true,
|
||||
HideFileExt: "",
|
||||
ShowCopyPath: false,
|
||||
DeleteAfterArchive: false,
|
||||
ViewMode: "list",
|
||||
GallerySize: 7,
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
Image: boolPtr(false),
|
||||
Video: boolPtr(false),
|
||||
Audio: boolPtr(false),
|
||||
MotionVideoPreview: boolPtr(false),
|
||||
Office: boolPtr(false),
|
||||
PopUp: boolPtr(false),
|
||||
DisablePreviewExt: ".exe",
|
||||
HighQuality: boolPtr(true),
|
||||
Folder: boolPtr(false),
|
||||
Models: boolPtr(false),
|
||||
},
|
||||
FileViewer: UserDefaultsFileViewer{
|
||||
EditorQuickSave: true,
|
||||
AutoplayMedia: boolPtr(false),
|
||||
DisableViewingExt: ".bat",
|
||||
DisableOnlyOfficeExt: ".x",
|
||||
PreferEditorForMarkdown: true,
|
||||
DebugOffice: true,
|
||||
DefaultMediaPlayer: true,
|
||||
},
|
||||
Search: UserDefaultsSearch{
|
||||
DisableOptions: true,
|
||||
},
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: &dark,
|
||||
ThemeColor: "var(--red)",
|
||||
CustomTheme: "Night",
|
||||
Locale: "de",
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 3,
|
||||
UploadChunkSize: 5,
|
||||
DownloadChunkSize: 2,
|
||||
},
|
||||
Account: UserDefaultsAccount{
|
||||
Permissions: UserDefaultsAccountPermissions{
|
||||
Api: true,
|
||||
Admin: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Realtime: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Download: &dlFalse,
|
||||
},
|
||||
LockPassword: true,
|
||||
DisableSettings: true,
|
||||
LoginMethod: "password",
|
||||
DisableUpdateNotifications: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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: ".x", // deprecated: mapped to DisableOnlyOfficeExt
|
||||
DisableOnlyOfficeExt: ".x",
|
||||
CustomTheme: "Night",
|
||||
ShowSelectMultiple: true,
|
||||
DebugOffice: true,
|
||||
DeleteWithoutConfirming: true,
|
||||
DeleteAfterArchive: false,
|
||||
PreferEditorForMarkdown: true,
|
||||
ShowToolsInSidebar: 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{
|
||||
Account: UserDefaultsAccount{
|
||||
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{
|
||||
Account: UserDefaultsAccount{
|
||||
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)
|
||||
}
|
||||
}
|
||||
+190
-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,9 @@ 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"`
|
||||
PasskeyAuth PasskeyAuthConfig `json:"passkey" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type PasswordAuthConfig struct {
|
||||
@@ -55,10 +73,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 +85,74 @@ 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"` // 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"
|
||||
}
|
||||
|
||||
// PasskeyAuthConfig configures WebAuthn / Passkey authentication as a second factor.
|
||||
type PasskeyAuthConfig struct {
|
||||
Enabled bool `json:"enabled"` // whether to enable passkey MFA
|
||||
RPDisplayName string `json:"rpDisplayName"` // the Relying Party display name (defaults to frontend name)
|
||||
RPID string `json:"rpId"` // the Relying Party ID; if empty, auto-derived from base URL host
|
||||
RPOrigins []string `json:"rpOrigins"` // allowed origins; if empty, auto-derived from base URL
|
||||
LoginButtonText string `json:"loginButtonText"` // custom text for the passkey login button
|
||||
}
|
||||
|
||||
// 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 +204,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
@@ -38,6 +39,8 @@ func Initialize(configFile string) {
|
||||
setupLogging()
|
||||
// setup logging first to ensure we log any errors
|
||||
setupEnv()
|
||||
// Migrate deprecated UserDefaults fields to new organized structure
|
||||
migrateUserDefaults()
|
||||
err = ValidateConfig(Config)
|
||||
if err != nil {
|
||||
errmsg := "The provided config file failed validation. "
|
||||
@@ -50,12 +53,13 @@ func Initialize(configFile string) {
|
||||
}
|
||||
setupFs()
|
||||
setupServer()
|
||||
setupHttp()
|
||||
setupAuth(false)
|
||||
setupSources(false)
|
||||
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
|
||||
setupUrls()
|
||||
setupFrontend(false)
|
||||
setupMedia()
|
||||
setupMedia(false)
|
||||
}
|
||||
|
||||
func setupServer() {
|
||||
@@ -80,6 +84,15 @@ func setupEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
func setupHttp() {
|
||||
if len(Config.Http.TrustedHeadersArray) > 0 {
|
||||
Config.Http.TrustedHeaders = make(map[string]bool)
|
||||
for _, header := range Config.Http.TrustedHeadersArray {
|
||||
Config.Http.TrustedHeaders[strings.ToLower(header)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupFs() {
|
||||
// Convert permission values (like 644, 755) to octal interpretation
|
||||
filePermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateFilePermission, 8, 32)
|
||||
@@ -92,11 +105,15 @@ 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()
|
||||
|
||||
if err := PrepareDownloadSpoolDir(); err != nil {
|
||||
logger.Fatalf("cacheDir failed to prepare download spool: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("cache directory setup successfully: %v", Config.Server.CacheDir)
|
||||
|
||||
}
|
||||
@@ -278,11 +295,9 @@ func setupFrontend(generate bool) {
|
||||
}
|
||||
// Load custom favicon if configured
|
||||
loadCustomFavicon()
|
||||
// Generate PWA icons from custom favicon if needed
|
||||
generatePWAIcons()
|
||||
}
|
||||
|
||||
func setupMedia() {
|
||||
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 {
|
||||
@@ -316,6 +331,19 @@ func setupMedia() {
|
||||
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 +366,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 +375,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,27 +450,45 @@ 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")
|
||||
}
|
||||
if Config.Auth.Methods.PasskeyAuth.Enabled || generate {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "passkey")
|
||||
logger.Info("Passkey Auth configured successfully")
|
||||
}
|
||||
|
||||
// use password auth as default if no auth methods are set
|
||||
if len(Config.Auth.AuthMethods) == 0 {
|
||||
Config.Auth.Methods.PasswordAuth.Enabled = true
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
|
||||
}
|
||||
Config.UserDefaults.LoginMethod = Config.Auth.AuthMethods[0]
|
||||
Config.UserDefaults.Account.LoginMethod = Config.Auth.AuthMethods[0]
|
||||
|
||||
}
|
||||
|
||||
@@ -454,31 +500,324 @@ func setupLogging() {
|
||||
},
|
||||
}
|
||||
}
|
||||
for _, logConfig := range Config.Server.Logging {
|
||||
for i := range Config.Server.Logging {
|
||||
cfg := &Config.Server.Logging[i]
|
||||
// Enable debug logging automatically in dev mode
|
||||
levels := logConfig.Levels
|
||||
levels := cfg.Levels
|
||||
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
|
||||
levels = "info|warning|error|debug"
|
||||
}
|
||||
|
||||
logConfig := logger.JsonConfig{
|
||||
Levels: levels,
|
||||
ApiLevels: logConfig.ApiLevels,
|
||||
Output: logConfig.Output,
|
||||
Utc: logConfig.Utc,
|
||||
NoColors: logConfig.NoColors,
|
||||
Json: logConfig.Json,
|
||||
Structured: false,
|
||||
pattern := cfg.ApiFilter
|
||||
if pattern == "" {
|
||||
pattern = "^/health|^/favicon.ico|^/static|^/public/static"
|
||||
}
|
||||
err := logger.EnableCompatibilityMode(logConfig)
|
||||
jsonCfg := logger.JsonConfig{
|
||||
Levels: levels,
|
||||
ApiLevels: cfg.ApiLevels,
|
||||
Output: cfg.Output,
|
||||
Utc: cfg.Utc,
|
||||
NoColors: cfg.NoColors,
|
||||
Json: cfg.Json,
|
||||
Structured: false,
|
||||
ApiPathExclude: pattern,
|
||||
}
|
||||
err := logger.EnableCompatibilityMode(jsonCfg)
|
||||
if err != nil {
|
||||
log.Println("[ERROR] Failed to set up logger:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// migrateUserDefaults migrates deprecated UserDefaults fields to the new organized structure.
|
||||
// This function maintains backwards compatibility by checking if new fields are unset and copying
|
||||
// values from old deprecated fields if found. Logs warnings when old fields are migrated.
|
||||
func migrateUserDefaults() {
|
||||
ud := &Config.UserDefaults
|
||||
hasOldFields := false
|
||||
|
||||
// Helper function to check if a bool pointer is unset (nil)
|
||||
isUnsetBoolPtr := func(v *bool) bool {
|
||||
return v == nil
|
||||
}
|
||||
|
||||
// Helper function to check if a string is unset (empty)
|
||||
isUnsetString := func(v string) bool {
|
||||
return v == ""
|
||||
}
|
||||
|
||||
// Helper function to check if an int is unset (zero)
|
||||
isUnsetInt := func(v int) bool {
|
||||
return v == 0
|
||||
}
|
||||
|
||||
// Migrate Preview-related deprecated fields
|
||||
if !ud.Sidebar.DisableHideOnPreview && ud.Preview.DisableHideSidebar {
|
||||
ud.Sidebar.DisableHideOnPreview = ud.Preview.DisableHideSidebar
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'preview.disableHideSidebar' to 'sidebar.disableHideOnPreview'")
|
||||
}
|
||||
|
||||
if !ud.FileViewer.DefaultMediaPlayer && ud.Preview.DefaultMediaPlayer {
|
||||
ud.FileViewer.DefaultMediaPlayer = ud.Preview.DefaultMediaPlayer
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'preview.defaultMediaPlayer' to 'fileViewer.defaultMediaPlayer'")
|
||||
}
|
||||
|
||||
if isUnsetBoolPtr(ud.FileViewer.AutoplayMedia) && ud.Preview.AutoplayMedia {
|
||||
autoplay := ud.Preview.AutoplayMedia
|
||||
ud.FileViewer.AutoplayMedia = &autoplay
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'preview.autoplayMedia' to 'fileViewer.autoplayMedia'")
|
||||
}
|
||||
|
||||
if isUnsetString(ud.Preview.DisablePreviewExt) && !isUnsetString(ud.DisablePreviewExt) {
|
||||
ud.Preview.DisablePreviewExt = ud.DisablePreviewExt
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'disablePreviewExt' to 'preview.disablePreviewExt'")
|
||||
}
|
||||
|
||||
// Migrate Sidebar fields
|
||||
if !ud.Sidebar.DisableQuickToggles && ud.DisableQuickToggles {
|
||||
ud.Sidebar.DisableQuickToggles = ud.DisableQuickToggles
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'disableQuickToggles' to 'sidebar.disableQuickToggles'")
|
||||
}
|
||||
|
||||
if !ud.Sidebar.HideFileActions && ud.HideSidebarFileActions {
|
||||
ud.Sidebar.HideFileActions = ud.HideSidebarFileActions
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'hideSidebarFileActions' to 'sidebar.hideFileActions'")
|
||||
}
|
||||
|
||||
if !ud.Sidebar.Sticky && ud.StickySidebar {
|
||||
ud.Sidebar.Sticky = ud.StickySidebar
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'stickySidebar' to 'sidebar.sticky'")
|
||||
}
|
||||
|
||||
if isUnsetString(ud.Listing.ViewMode) && !isUnsetString(ud.ViewMode) {
|
||||
ud.Listing.ViewMode = ud.ViewMode
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'viewMode' to 'sidebar.viewMode'")
|
||||
}
|
||||
|
||||
if isUnsetInt(ud.Listing.GallerySize) && !isUnsetInt(ud.GallerySize) {
|
||||
ud.Listing.GallerySize = ud.GallerySize
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'gallerySize' to 'sidebar.gallerySize'")
|
||||
}
|
||||
|
||||
if !ud.Sidebar.HideFiles && ud.HideFilesInTree {
|
||||
ud.Sidebar.HideFiles = ud.HideFilesInTree
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'hideFilesInTree' to 'sidebar.hideFiles'")
|
||||
}
|
||||
|
||||
if isUnsetBoolPtr(ud.Sidebar.ShowTools) && ud.ShowToolsInSidebar != nil {
|
||||
ud.Sidebar.ShowTools = ud.ShowToolsInSidebar
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'showToolsInSidebar' to 'sidebar.showTools'")
|
||||
}
|
||||
|
||||
// Migrate Listing fields
|
||||
if !ud.Listing.DeleteWithoutConfirming && ud.DeleteWithoutConfirming {
|
||||
ud.Listing.DeleteWithoutConfirming = ud.DeleteWithoutConfirming
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'deleteWithoutConfirming' to 'listing.deleteWithoutConfirming'")
|
||||
}
|
||||
|
||||
if !ud.Listing.DateFormat && ud.DateFormat {
|
||||
ud.Listing.DateFormat = ud.DateFormat
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'dateFormat' to 'listing.dateFormat'")
|
||||
}
|
||||
|
||||
if !ud.Listing.ShowHidden && ud.ShowHidden {
|
||||
ud.Listing.ShowHidden = ud.ShowHidden
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'showHidden' to 'listing.showHidden'")
|
||||
}
|
||||
|
||||
if !ud.Listing.QuickDownload && ud.QuickDownload {
|
||||
ud.Listing.QuickDownload = ud.QuickDownload
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'quickDownload' to 'listing.quickDownload'")
|
||||
}
|
||||
|
||||
if !ud.Listing.ShowSelectMultiple && ud.ShowSelectMultiple {
|
||||
ud.Listing.ShowSelectMultiple = ud.ShowSelectMultiple
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'showSelectMultiple' to 'listing.showSelectMultiple'")
|
||||
}
|
||||
|
||||
if !ud.Listing.SingleClick && ud.SingleClick {
|
||||
ud.Listing.SingleClick = ud.SingleClick
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'singleClick' to 'listing.singleClick'")
|
||||
}
|
||||
|
||||
if isUnsetString(ud.Listing.HideFileExt) && !isUnsetString(ud.HideFileExt) {
|
||||
ud.Listing.HideFileExt = ud.HideFileExt
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'hideFileExt' to 'listing.hideFileExt'")
|
||||
}
|
||||
|
||||
if !ud.Listing.ShowCopyPath && ud.ShowCopyPath {
|
||||
ud.Listing.ShowCopyPath = ud.ShowCopyPath
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'showCopyPath' to 'listing.showCopyPath'")
|
||||
}
|
||||
|
||||
if !ud.Listing.DeleteAfterArchive && ud.DeleteAfterArchive {
|
||||
ud.Listing.DeleteAfterArchive = ud.DeleteAfterArchive
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'deleteAfterArchive' to 'listing.deleteAfterArchive'")
|
||||
}
|
||||
|
||||
// Migrate FileViewer fields
|
||||
if !ud.FileViewer.EditorQuickSave && ud.EditorQuickSave {
|
||||
ud.FileViewer.EditorQuickSave = ud.EditorQuickSave
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'editorQuickSave' to 'fileViewer.editorQuickSave'")
|
||||
}
|
||||
|
||||
if isUnsetString(ud.FileViewer.DisableViewingExt) && !isUnsetString(ud.DisableViewingExt) {
|
||||
ud.FileViewer.DisableViewingExt = ud.DisableViewingExt
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'disableViewingExt' to 'fileViewer.disableViewingExt'")
|
||||
}
|
||||
|
||||
if isUnsetString(ud.FileViewer.DisableOnlyOfficeExt) && !isUnsetString(ud.DisableOnlyOfficeExt) {
|
||||
ud.FileViewer.DisableOnlyOfficeExt = ud.DisableOnlyOfficeExt
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'disableOnlyOfficeExt' to 'fileViewer.disableOnlyOfficeExt'")
|
||||
}
|
||||
|
||||
if !ud.FileViewer.PreferEditorForMarkdown && ud.PreferEditorForMarkdown {
|
||||
ud.FileViewer.PreferEditorForMarkdown = ud.PreferEditorForMarkdown
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'preferEditorForMarkdown' to 'fileViewer.preferEditorForMarkdown'")
|
||||
}
|
||||
|
||||
if !ud.FileViewer.DebugOffice && ud.DebugOffice {
|
||||
ud.FileViewer.DebugOffice = ud.DebugOffice
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'debugOffice' to 'fileViewer.debugOffice'")
|
||||
}
|
||||
|
||||
// Migrate Search fields
|
||||
if !ud.Search.DisableOptions && ud.DisableSearchOptions {
|
||||
ud.Search.DisableOptions = ud.DisableSearchOptions
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'disableSearchOptions' to 'search.disableOptions'")
|
||||
}
|
||||
|
||||
// Migrate UI fields
|
||||
if isUnsetBoolPtr(ud.UI.DarkMode) && ud.DarkMode != nil {
|
||||
ud.UI.DarkMode = ud.DarkMode
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'darkMode' to 'ui.darkMode'")
|
||||
}
|
||||
|
||||
if isUnsetString(ud.UI.ThemeColor) && !isUnsetString(ud.ThemeColor) {
|
||||
ud.UI.ThemeColor = ud.ThemeColor
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'themeColor' to 'ui.themeColor'")
|
||||
}
|
||||
|
||||
if isUnsetString(ud.UI.CustomTheme) && !isUnsetString(ud.CustomTheme) {
|
||||
ud.UI.CustomTheme = ud.CustomTheme
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'customTheme' to 'ui.customTheme'")
|
||||
}
|
||||
|
||||
if isUnsetString(ud.UI.Locale) && !isUnsetString(ud.Locale) {
|
||||
ud.UI.Locale = ud.Locale
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'locale' to 'ui.locale'")
|
||||
}
|
||||
|
||||
// Migrate Account fields
|
||||
if !ud.Account.LockPassword && ud.LockPassword {
|
||||
ud.Account.LockPassword = ud.LockPassword
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'lockPassword' to 'account.lockPassword'")
|
||||
}
|
||||
|
||||
if !ud.Account.DisableSettings && ud.DisableSettings {
|
||||
ud.Account.DisableSettings = ud.DisableSettings
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'disableSettings' to 'account.disableSettings'")
|
||||
}
|
||||
|
||||
if isUnsetString(ud.Account.LoginMethod) && !isUnsetString(ud.LoginMethod) {
|
||||
ud.Account.LoginMethod = ud.LoginMethod
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'loginMethod' to 'account.loginMethod'")
|
||||
}
|
||||
|
||||
if !ud.Account.DisableUpdateNotifications && ud.DisableUpdateNotifications {
|
||||
ud.Account.DisableUpdateNotifications = ud.DisableUpdateNotifications
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'disableUpdateNotifications' to 'account.disableUpdateNotifications'")
|
||||
}
|
||||
|
||||
// Migrate Account Permissions fields
|
||||
if !ud.Account.Permissions.Api && ud.Permissions.Api {
|
||||
ud.Account.Permissions.Api = ud.Permissions.Api
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'permissions.api' to 'account.permissions.api'")
|
||||
}
|
||||
|
||||
if !ud.Account.Permissions.Admin && ud.Permissions.Admin {
|
||||
ud.Account.Permissions.Admin = ud.Permissions.Admin
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'permissions.admin' to 'account.permissions.admin'")
|
||||
}
|
||||
|
||||
if !ud.Account.Permissions.Modify && ud.Permissions.Modify {
|
||||
ud.Account.Permissions.Modify = ud.Permissions.Modify
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'permissions.modify' to 'account.permissions.modify'")
|
||||
}
|
||||
|
||||
if !ud.Account.Permissions.Share && ud.Permissions.Share {
|
||||
ud.Account.Permissions.Share = ud.Permissions.Share
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'permissions.share' to 'account.permissions.share'")
|
||||
}
|
||||
|
||||
if !ud.Account.Permissions.Realtime && ud.Permissions.Realtime {
|
||||
ud.Account.Permissions.Realtime = ud.Permissions.Realtime
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'permissions.realtime' to 'account.permissions.realtime'")
|
||||
}
|
||||
|
||||
if !ud.Account.Permissions.Delete && ud.Permissions.Delete {
|
||||
ud.Account.Permissions.Delete = ud.Permissions.Delete
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'permissions.delete' to 'account.permissions.delete'")
|
||||
}
|
||||
|
||||
if !ud.Account.Permissions.Create && ud.Permissions.Create {
|
||||
ud.Account.Permissions.Create = ud.Permissions.Create
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'permissions.create' to 'account.permissions.create'")
|
||||
}
|
||||
|
||||
if isUnsetBoolPtr(ud.Account.Permissions.Download) && ud.Permissions.Download != nil {
|
||||
ud.Account.Permissions.Download = ud.Permissions.Download
|
||||
hasOldFields = true
|
||||
logger.Warning("userDefaults: migrating deprecated field 'permissions.download' to 'account.permissions.download'")
|
||||
}
|
||||
|
||||
if hasOldFields {
|
||||
logger.Warning("userDefaults: Please update your configuration to use the new organized structure. See documentation for details.")
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
Config = setDefaults(generate)
|
||||
Config = SetDefaults(generate)
|
||||
|
||||
// Check if config file exists
|
||||
if _, err := os.Stat(configFile); err != nil {
|
||||
@@ -624,9 +963,14 @@ 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 {
|
||||
func SetDefaults(generate bool) Settings {
|
||||
// get number of CPUs available
|
||||
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
|
||||
cpus := runtime.NumCPU()
|
||||
@@ -636,14 +980,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 +992,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",
|
||||
@@ -681,38 +1021,79 @@ func setDefaults(generate bool) Settings {
|
||||
Name: "FileBrowser Quantum",
|
||||
},
|
||||
UserDefaults: UserDefaults{
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
LockPassword: false,
|
||||
ShowHidden: false,
|
||||
DarkMode: boolPtr(true),
|
||||
DisableSettings: false,
|
||||
ViewMode: "normal",
|
||||
Locale: "en",
|
||||
GallerySize: 3,
|
||||
ThemeColor: "var(--blue)",
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Modify: false,
|
||||
Share: false,
|
||||
Admin: false,
|
||||
Api: false,
|
||||
Download: boolPtr(true), // defaults to true
|
||||
// New organized structure
|
||||
Sidebar: UserDefaultsSidebar{
|
||||
DisableQuickToggles: false,
|
||||
HideFileActions: false,
|
||||
DisableHideOnPreview: false,
|
||||
Sticky: true,
|
||||
HideFiles: false,
|
||||
ShowTools: boolPtr(true),
|
||||
},
|
||||
Listing: UserDefaultsListing{
|
||||
DeleteWithoutConfirming: false,
|
||||
DateFormat: false,
|
||||
ShowHidden: false,
|
||||
QuickDownload: false,
|
||||
ShowSelectMultiple: false,
|
||||
SingleClick: false,
|
||||
HideFileExt: "",
|
||||
ShowCopyPath: false,
|
||||
DeleteAfterArchive: true,
|
||||
ViewMode: "normal",
|
||||
GallerySize: 3,
|
||||
},
|
||||
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),
|
||||
DisablePreviewExt: "",
|
||||
HighQuality: boolPtr(true),
|
||||
Folder: boolPtr(true),
|
||||
Models: boolPtr(true),
|
||||
},
|
||||
FileViewer: UserDefaultsFileViewer{
|
||||
EditorQuickSave: false,
|
||||
AutoplayMedia: boolPtr(true),
|
||||
DisableViewingExt: "",
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
PreferEditorForMarkdown: false,
|
||||
DebugOffice: false,
|
||||
DefaultMediaPlayer: false,
|
||||
},
|
||||
Search: UserDefaultsSearch{
|
||||
DisableOptions: false,
|
||||
},
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: boolPtr(true),
|
||||
ThemeColor: "var(--blue)",
|
||||
CustomTheme: "",
|
||||
Locale: "en",
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
DownloadChunkSize: 0, // 0MB, default to no chunking
|
||||
},
|
||||
Account: UserDefaultsAccount{
|
||||
Permissions: UserDefaultsAccountPermissions{
|
||||
Api: false,
|
||||
Admin: false,
|
||||
Modify: false,
|
||||
Share: false,
|
||||
Realtime: false,
|
||||
Delete: false,
|
||||
Create: false,
|
||||
Download: boolPtr(true), // defaults to true
|
||||
},
|
||||
LockPassword: false,
|
||||
DisableSettings: false,
|
||||
LoginMethod: "",
|
||||
DisableUpdateNotifications: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initialize ImagePreview map with defaults
|
||||
@@ -786,23 +1167,26 @@ 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).
|
||||
}
|
||||
|
||||
// generatePWAIcons sets up PWA icon paths (actual generation happens after preview service starts)
|
||||
func generatePWAIcons() {
|
||||
// Set PWA icons directory in cache
|
||||
Env.PWAIconsDir = filepath.Join(Config.Server.CacheDir, "icons")
|
||||
// PWAIconsCacheDir is where startup icon generation writes PNGs (under the server cache dir).
|
||||
func PWAIconsCacheDir() string {
|
||||
return filepath.Join(Config.Server.CacheDir, "icons")
|
||||
}
|
||||
|
||||
// Initialize with default paths (all icons will be generated)
|
||||
Env.PWAIcon192 = "icons/pwa-icon-192.png"
|
||||
Env.PWAIcon256 = "icons/pwa-icon-256.png"
|
||||
Env.PWAIcon512 = "icons/pwa-icon-512.png"
|
||||
// Where transient archives for multi-request (Range) downloads are stored to support chunked downloads.
|
||||
func DownloadCacheDir() string {
|
||||
return filepath.Join(Config.Server.CacheDir, "downloads")
|
||||
}
|
||||
|
||||
// Create icons directory with configured permissions
|
||||
// Note: Parent cache directory should already exist from testCacheDirSpeed()
|
||||
if err := os.MkdirAll(Env.PWAIconsDir, fileutils.PermDir); err != nil {
|
||||
logger.Warningf("Failed to create PWA icons directory %s: %v", Env.PWAIconsDir, err)
|
||||
// PrepareDownloadSpoolDir creates the download spool directory and removes any leftover dl-archive-*
|
||||
func PrepareDownloadSpoolDir() error {
|
||||
if err := os.MkdirAll(DownloadCacheDir(), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutils.ClearDirectoryContents(DownloadCacheDir())
|
||||
}
|
||||
|
||||
func loadLoginIcon() {
|
||||
@@ -856,26 +1240,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
|
||||
|
||||
@@ -35,7 +35,7 @@ func Test_setDefaults(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := setDefaults(true); !reflect.DeepEqual(got, tt.want) {
|
||||
if got := SetDefaults(true); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -54,7 +54,7 @@ func TestConfigLoadChanged(t *testing.T) {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
defaultConfig := setDefaults(true)
|
||||
defaultConfig := SetDefaults(true)
|
||||
err = loadConfigWithDefaults(configFile, true)
|
||||
if err != nil {
|
||||
t.Fatalf("error loading config file: %v", err)
|
||||
@@ -77,7 +77,7 @@ func TestConfigLoadEnvVars(t *testing.T) {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
defaultConfig := setDefaults(true)
|
||||
defaultConfig := SetDefaults(true)
|
||||
expectedKey := "MYKEY"
|
||||
// mock environment variables
|
||||
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
|
||||
@@ -106,7 +106,7 @@ func TestConfigLoadSpecificValues(t *testing.T) {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
defaultConfig := setDefaults(true)
|
||||
defaultConfig := SetDefaults(true)
|
||||
err = loadConfigWithDefaults(configFile, true)
|
||||
if err != nil {
|
||||
t.Fatalf("error loading config file: %v", err)
|
||||
|
||||
@@ -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
|
||||
@@ -243,7 +286,7 @@ func buildPathMapRecursive(structType reflect.Type, structValue reflect.Value, c
|
||||
fieldType := field.Type
|
||||
|
||||
// Handle pointers
|
||||
if fieldType.Kind() == reflect.Ptr {
|
||||
if fieldType.Kind() == reflect.Pointer {
|
||||
if fieldValue.IsNil() {
|
||||
// Skip nil pointers
|
||||
continue
|
||||
@@ -436,7 +479,7 @@ func CollectCommentsFromEmbeddedYaml(yamlContent string) (CommentsMap, SecretFie
|
||||
// parseDefaultsFromEmbeddedYaml creates a default config by parsing the embedded YAML template
|
||||
func parseDefaultsFromEmbeddedYaml(embeddedYaml string) (*Settings, error) {
|
||||
// Start with baseline defaults from setDefaults to ensure all fields are initialized
|
||||
defaultConfig := setDefaults(true)
|
||||
defaultConfig := SetDefaults(true)
|
||||
defaultConfig.Server.Sources = []*Source{{Path: "."}}
|
||||
|
||||
// Parse the embedded YAML to overlay the documented defaults
|
||||
@@ -473,7 +516,7 @@ func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
|
||||
// buildNodeWithDefaults constructs a yaml.Node for any Go value, skipping fields that match defaults, redacting secrets, and filtering deprecated fields
|
||||
func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.Value, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
|
||||
// Dereference pointers
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
||||
}
|
||||
@@ -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]
|
||||
@@ -625,7 +790,7 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
|
||||
elemType := v.Type().Elem()
|
||||
// Check if element is a struct or pointer to struct
|
||||
isStructSlice := elemType.Kind() == reflect.Struct ||
|
||||
(elemType.Kind() == reflect.Ptr && elemType.Elem().Kind() == reflect.Struct)
|
||||
(elemType.Kind() == reflect.Pointer && elemType.Elem().Kind() == reflect.Struct)
|
||||
if !isStructSlice {
|
||||
seq.Style = yaml.FlowStyle
|
||||
}
|
||||
@@ -712,25 +877,11 @@ func GenerateYaml() {
|
||||
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
|
||||
@@ -844,7 +995,7 @@ func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFul
|
||||
|
||||
// identifySecretFieldsByReflection identifies secret fields by known field names
|
||||
func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets SecretFieldsMap) {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return
|
||||
}
|
||||
@@ -879,9 +1030,9 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
|
||||
|
||||
// Recursively check nested structs
|
||||
fieldValue := v.Field(i)
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
nestedTypeName := field.Type.Name()
|
||||
if field.Type.Kind() == reflect.Ptr {
|
||||
if field.Type.Kind() == reflect.Pointer {
|
||||
nestedTypeName = field.Type.Elem().Name()
|
||||
}
|
||||
identifySecretFieldsByReflection(fieldValue, nestedTypeName, secrets)
|
||||
@@ -891,7 +1042,7 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
|
||||
|
||||
// identifyDeprecatedFieldsByReflection identifies deprecated fields by known field names
|
||||
func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, deprecated DeprecatedFieldsMap) {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return
|
||||
}
|
||||
@@ -921,9 +1072,9 @@ func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, depr
|
||||
|
||||
// Recursively check nested structs
|
||||
fieldValue := v.Field(i)
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
nestedTypeName := field.Type.Name()
|
||||
if field.Type.Kind() == reflect.Ptr {
|
||||
if field.Type.Kind() == reflect.Pointer {
|
||||
nestedTypeName = field.Type.Elem().Name()
|
||||
}
|
||||
identifyDeprecatedFieldsByReflection(fieldValue, nestedTypeName, deprecated)
|
||||
@@ -957,7 +1108,7 @@ func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, e
|
||||
}
|
||||
if err != nil || readErr != nil {
|
||||
// Fallback to setDefaults
|
||||
defaultConfigValue := setDefaults(true)
|
||||
defaultConfigValue := SetDefaults(true)
|
||||
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
|
||||
defaultConfig = &defaultConfigValue
|
||||
}
|
||||
@@ -1050,7 +1201,7 @@ func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull
|
||||
}
|
||||
if err != nil || readErr != nil {
|
||||
// Fallback to setDefaults
|
||||
defaultConfigValue := setDefaults(true)
|
||||
defaultConfigValue := SetDefaults(true)
|
||||
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
|
||||
defaultConfig = &defaultConfigValue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// TestMigrateUserDefaults_OldToNew verifies that deprecated fields are correctly migrated to new fields
|
||||
func TestMigrateUserDefaults_OldToNew(t *testing.T) {
|
||||
// Setup: Create UserDefaults with old fields set
|
||||
Config.UserDefaults = UserDefaults{
|
||||
// Old deprecated fields
|
||||
DarkMode: boolPtr(true),
|
||||
DisableQuickToggles: true,
|
||||
HideSidebarFileActions: true,
|
||||
StickySidebar: true,
|
||||
SingleClick: true,
|
||||
HideFilesInTree: true,
|
||||
ShowToolsInSidebar: boolPtr(false),
|
||||
DeleteWithoutConfirming: true,
|
||||
DateFormat: true,
|
||||
ShowHidden: true,
|
||||
QuickDownload: true,
|
||||
ShowSelectMultiple: true,
|
||||
HideFileExt: ".tmp",
|
||||
ShowCopyPath: true,
|
||||
DeleteAfterArchive: true,
|
||||
DisablePreviewExt: ".exe",
|
||||
EditorQuickSave: true,
|
||||
DisableViewingExt: ".bin",
|
||||
DisableOnlyOfficeExt: ".md",
|
||||
PreferEditorForMarkdown: true,
|
||||
DebugOffice: true,
|
||||
DisableSearchOptions: true,
|
||||
ThemeColor: "var(--red)",
|
||||
CustomTheme: "dark",
|
||||
Locale: "de",
|
||||
LockPassword: true,
|
||||
DisableSettings: true,
|
||||
LoginMethod: "oidc",
|
||||
DisableUpdateNotifications: true,
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Api: true,
|
||||
Admin: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Realtime: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Download: boolPtr(false),
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
Image: boolPtr(true),
|
||||
Video: boolPtr(true),
|
||||
Audio: boolPtr(true),
|
||||
MotionVideoPreview: boolPtr(true),
|
||||
Office: boolPtr(true),
|
||||
PopUp: boolPtr(true),
|
||||
HighQuality: boolPtr(true),
|
||||
Folder: boolPtr(true),
|
||||
Models: boolPtr(true),
|
||||
// Deprecated fields that should be migrated
|
||||
DisableHideSidebar: true,
|
||||
AutoplayMedia: true,
|
||||
DefaultMediaPlayer: true,
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 5,
|
||||
UploadChunkSize: 20,
|
||||
},
|
||||
}
|
||||
|
||||
// Execute: Run migration
|
||||
migrateUserDefaults()
|
||||
|
||||
// Verify: Check that new fields are populated from old fields
|
||||
ud := &Config.UserDefaults
|
||||
|
||||
// Sidebar fields
|
||||
if !ud.Sidebar.DisableQuickToggles {
|
||||
t.Error("sidebar.disableQuickToggles should be true")
|
||||
}
|
||||
if !ud.Sidebar.HideFileActions {
|
||||
t.Error("sidebar.hideFileActions should be true")
|
||||
}
|
||||
if !ud.Sidebar.Sticky {
|
||||
t.Error("sidebar.sticky should be true")
|
||||
}
|
||||
if !ud.Sidebar.HideFiles {
|
||||
t.Error("sidebar.hideFiles should be true")
|
||||
}
|
||||
if ud.Sidebar.ShowTools == nil || *ud.Sidebar.ShowTools != false {
|
||||
t.Error("sidebar.showTools should be false")
|
||||
}
|
||||
if !ud.Sidebar.DisableHideOnPreview {
|
||||
t.Error("sidebar.disableHideOnPreview should be true")
|
||||
}
|
||||
|
||||
// Listing fields
|
||||
if !ud.Listing.DeleteWithoutConfirming {
|
||||
t.Error("listing.deleteWithoutConfirming should be true")
|
||||
}
|
||||
if !ud.Listing.DateFormat {
|
||||
t.Error("listing.dateFormat should be true")
|
||||
}
|
||||
if !ud.Listing.ShowHidden {
|
||||
t.Error("listing.showHidden should be true")
|
||||
}
|
||||
if !ud.Listing.QuickDownload {
|
||||
t.Error("listing.quickDownload should be true")
|
||||
}
|
||||
if !ud.Listing.ShowSelectMultiple {
|
||||
t.Error("listing.showSelectMultiple should be true")
|
||||
}
|
||||
if !ud.Listing.SingleClick {
|
||||
t.Error("listing.singleClick should be true")
|
||||
}
|
||||
if ud.Listing.HideFileExt != ".tmp" {
|
||||
t.Errorf("listing.hideFileExt should be '.tmp', got '%s'", ud.Listing.HideFileExt)
|
||||
}
|
||||
if !ud.Listing.ShowCopyPath {
|
||||
t.Error("listing.showCopyPath should be true")
|
||||
}
|
||||
if !ud.Listing.DeleteAfterArchive {
|
||||
t.Error("listing.deleteAfterArchive should be true")
|
||||
}
|
||||
|
||||
// Preview fields
|
||||
if ud.Preview.Image == nil || *ud.Preview.Image != true {
|
||||
t.Error("preview.image should be true")
|
||||
}
|
||||
if ud.Preview.Video == nil || *ud.Preview.Video != true {
|
||||
t.Error("preview.video should be true")
|
||||
}
|
||||
if ud.Preview.Audio == nil || *ud.Preview.Audio != true {
|
||||
t.Error("preview.audio should be true")
|
||||
}
|
||||
if ud.Preview.DisablePreviewExt != ".exe" {
|
||||
t.Errorf("preview.disablePreviewExt should be '.exe', got '%s'", ud.Preview.DisablePreviewExt)
|
||||
}
|
||||
|
||||
// FileViewer fields
|
||||
if !ud.FileViewer.EditorQuickSave {
|
||||
t.Error("fileViewer.editorQuickSave should be true")
|
||||
}
|
||||
if ud.FileViewer.AutoplayMedia == nil || *ud.FileViewer.AutoplayMedia != true {
|
||||
t.Error("fileViewer.autoplayMedia should be true")
|
||||
}
|
||||
if ud.FileViewer.DisableViewingExt != ".bin" {
|
||||
t.Errorf("fileViewer.disableViewingExt should be '.bin', got '%s'", ud.FileViewer.DisableViewingExt)
|
||||
}
|
||||
if ud.FileViewer.DisableOnlyOfficeExt != ".md" {
|
||||
t.Errorf("fileViewer.disableOnlyOfficeExt should be '.md', got '%s'", ud.FileViewer.DisableOnlyOfficeExt)
|
||||
}
|
||||
if !ud.FileViewer.PreferEditorForMarkdown {
|
||||
t.Error("fileViewer.preferEditorForMarkdown should be true")
|
||||
}
|
||||
if !ud.FileViewer.DebugOffice {
|
||||
t.Error("fileViewer.debugOffice should be true")
|
||||
}
|
||||
|
||||
// Search fields
|
||||
if !ud.Search.DisableOptions {
|
||||
t.Error("search.disableOptions should be true")
|
||||
}
|
||||
|
||||
// UI fields
|
||||
if ud.UI.DarkMode == nil || *ud.UI.DarkMode != true {
|
||||
t.Error("ui.darkMode should be true")
|
||||
}
|
||||
if ud.UI.ThemeColor != "var(--red)" {
|
||||
t.Errorf("ui.themeColor should be 'var(--red)', got '%s'", ud.UI.ThemeColor)
|
||||
}
|
||||
if ud.UI.CustomTheme != "dark" {
|
||||
t.Errorf("ui.customTheme should be 'dark', got '%s'", ud.UI.CustomTheme)
|
||||
}
|
||||
if ud.UI.Locale != "de" {
|
||||
t.Errorf("ui.locale should be 'de', got '%s'", ud.UI.Locale)
|
||||
}
|
||||
|
||||
// Account fields
|
||||
if !ud.Account.LockPassword {
|
||||
t.Error("account.lockPassword should be true")
|
||||
}
|
||||
if !ud.Account.DisableSettings {
|
||||
t.Error("account.disableSettings should be true")
|
||||
}
|
||||
if ud.Account.LoginMethod != "oidc" {
|
||||
t.Errorf("account.loginMethod should be 'oidc', got '%s'", ud.Account.LoginMethod)
|
||||
}
|
||||
if !ud.Account.DisableUpdateNotifications {
|
||||
t.Error("account.disableUpdateNotifications should be true")
|
||||
}
|
||||
|
||||
// Account Permissions
|
||||
if !ud.Account.Permissions.Api {
|
||||
t.Error("account.permissions.api should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Admin {
|
||||
t.Error("account.permissions.admin should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Modify {
|
||||
t.Error("account.permissions.modify should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Share {
|
||||
t.Error("account.permissions.share should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Realtime {
|
||||
t.Error("account.permissions.realtime should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Delete {
|
||||
t.Error("account.permissions.delete should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Create {
|
||||
t.Error("account.permissions.create should be true")
|
||||
}
|
||||
if ud.Account.Permissions.Download == nil || *ud.Account.Permissions.Download != false {
|
||||
t.Error("account.permissions.download should be false")
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
|
||||
}
|
||||
|
||||
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
|
||||
func ConvertPermissionsToUsers(p UserDefaultsPermissions) users.Permissions {
|
||||
func ConvertPermissionsToUsers(p UserDefaultsAccountPermissions) users.Permissions {
|
||||
return users.Permissions{
|
||||
Api: p.Api,
|
||||
Admin: p.Admin,
|
||||
@@ -57,57 +57,100 @@ func GetSettingsConfig(nameType string, Value string) string {
|
||||
|
||||
func AdminPerms() users.Permissions {
|
||||
return users.Permissions{
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Admin: true,
|
||||
Api: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Admin: true,
|
||||
Api: true,
|
||||
Download: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Realtime: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Account settings
|
||||
u.DisableSettings = d.Account.DisableSettings
|
||||
u.LockPassword = d.Account.LockPassword
|
||||
u.DisableUpdateNotifications = d.Account.DisableUpdateNotifications
|
||||
|
||||
u.Locale = Config.UserDefaults.Locale
|
||||
u.ViewMode = Config.UserDefaults.ViewMode
|
||||
u.SingleClick = Config.UserDefaults.SingleClick
|
||||
// Sidebar settings
|
||||
u.DisableQuickToggles = d.Sidebar.DisableQuickToggles
|
||||
u.HideSidebarFileActions = d.Sidebar.HideFileActions
|
||||
u.StickySidebar = d.Sidebar.Sticky
|
||||
u.HideFilesInTree = d.Sidebar.HideFiles
|
||||
u.ShowToolsInSidebar = boolValueOrDefault(d.Sidebar.ShowTools, true)
|
||||
|
||||
// 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)
|
||||
// Listing settings
|
||||
u.DeleteWithoutConfirming = d.Listing.DeleteWithoutConfirming
|
||||
u.DateFormat = d.Listing.DateFormat
|
||||
u.ShowHidden = d.Listing.ShowHidden
|
||||
u.QuickDownload = d.Listing.QuickDownload
|
||||
u.ShowSelectMultiple = d.Listing.ShowSelectMultiple
|
||||
u.SingleClick = d.Listing.SingleClick
|
||||
u.HideFileExt = d.Listing.HideFileExt
|
||||
u.ShowCopyPath = d.Listing.ShowCopyPath
|
||||
u.DeleteAfterArchive = d.Listing.DeleteAfterArchive
|
||||
u.ViewMode = d.Listing.ViewMode
|
||||
u.GallerySize = d.Listing.GallerySize
|
||||
|
||||
// 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)
|
||||
// Preview settings
|
||||
u.Preview.DisableHideSidebar = d.Sidebar.DisableHideOnPreview
|
||||
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.Folder = boolValueOrDefault(d.Preview.Folder, true)
|
||||
u.Preview.Models = boolValueOrDefault(d.Preview.Models, true)
|
||||
u.DisablePreviewExt = d.Preview.DisablePreviewExt
|
||||
|
||||
// FileViewer settings
|
||||
u.EditorQuickSave = d.FileViewer.EditorQuickSave
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(d.FileViewer.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = d.FileViewer.DefaultMediaPlayer
|
||||
u.DisableViewingExt = d.FileViewer.DisableViewingExt
|
||||
u.DisableOnlyOfficeExt = d.FileViewer.DisableOnlyOfficeExt
|
||||
u.DisableOfficePreviewExt = d.FileViewer.DisableOnlyOfficeExt // deprecated field, map to same source
|
||||
u.PreferEditorForMarkdown = d.FileViewer.PreferEditorForMarkdown
|
||||
u.DebugOffice = d.FileViewer.DebugOffice
|
||||
|
||||
// Search settings
|
||||
u.DisableSearchOptions = d.Search.DisableOptions
|
||||
|
||||
// UI settings
|
||||
u.DarkMode = boolValueOrDefault(d.UI.DarkMode, true)
|
||||
u.ThemeColor = d.UI.ThemeColor
|
||||
u.CustomTheme = d.UI.CustomTheme
|
||||
u.Locale = d.UI.Locale
|
||||
|
||||
// FileLoading settings
|
||||
u.FileLoading = d.FileLoading
|
||||
|
||||
if u.Username == "anonymous" {
|
||||
return
|
||||
}
|
||||
|
||||
// Permissions
|
||||
u.Permissions.Api = d.Account.Permissions.Api
|
||||
u.Permissions.Admin = d.Account.Permissions.Admin
|
||||
u.Permissions.Modify = d.Account.Permissions.Modify
|
||||
u.Permissions.Share = d.Account.Permissions.Share
|
||||
u.Permissions.Realtime = d.Account.Permissions.Realtime
|
||||
u.Permissions.Delete = d.Account.Permissions.Delete
|
||||
u.Permissions.Create = d.Account.Permissions.Create
|
||||
u.Permissions.Download = boolValueOrDefault(d.Account.Permissions.Download, true)
|
||||
|
||||
if u.LoginMethod == "" && d.Account.LoginMethod != "" {
|
||||
u.LoginMethod = users.LoginMethod(d.Account.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 {
|
||||
@@ -115,7 +158,16 @@ func ApplyUserDefaults(u *users.User) {
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: source.Config.DefaultUserScope,
|
||||
})
|
||||
u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{
|
||||
Name: source.Name,
|
||||
Category: "source",
|
||||
Target: "/",
|
||||
Icon: "",
|
||||
SourceName: source.Path,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.Version = users.CurrentUserMigrationVersion
|
||||
}
|
||||
@@ -37,12 +37,12 @@ func (s *Storage) Save(set *Settings) error {
|
||||
return errors.ErrEmptyKey
|
||||
}
|
||||
|
||||
if set.UserDefaults.Locale == "" {
|
||||
set.UserDefaults.Locale = "en"
|
||||
if set.UserDefaults.UI.Locale == "" {
|
||||
set.UserDefaults.UI.Locale = "en"
|
||||
}
|
||||
|
||||
if set.UserDefaults.ViewMode == "" {
|
||||
set.UserDefaults.ViewMode = "normal"
|
||||
if set.UserDefaults.Listing.ViewMode == "" {
|
||||
set.UserDefaults.Listing.ViewMode = "normal"
|
||||
}
|
||||
|
||||
err := s.back.Save(set)
|
||||
|
||||
@@ -20,6 +20,14 @@ type Settings struct {
|
||||
Frontend Frontend `json:"frontend"`
|
||||
UserDefaults UserDefaults `json:"userDefaults"`
|
||||
Integrations Integrations `json:"integrations"`
|
||||
Http Http `json:"http"`
|
||||
}
|
||||
type Http struct {
|
||||
TrustedHeadersArray []string `json:"trustedHeaders"` // list of headers to trust, useful when behind a reverse proxy.
|
||||
DisableRateLimit bool `json:"disableRateLimit"` // turns off built-in auth route rate limiting and failed-login lockout (default false).
|
||||
|
||||
// internal map of trusted headers
|
||||
TrustedHeaders map[string]bool `json:"-"`
|
||||
}
|
||||
|
||||
type Environment struct {
|
||||
@@ -36,10 +44,6 @@ type Environment struct {
|
||||
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
|
||||
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
|
||||
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
|
||||
PWAIconsDir string `json:"-"` // directory where generated PWA icons are stored
|
||||
PWAIcon192 string `json:"-"` // path to 192x192 PWA icon
|
||||
PWAIcon256 string `json:"-"` // path to 256x256 PWA icon
|
||||
PWAIcon512 string `json:"-"` // path to 512x512 PWA icon
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -62,10 +66,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
|
||||
@@ -76,11 +80,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 {
|
||||
@@ -102,6 +117,7 @@ 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 {
|
||||
@@ -183,9 +199,9 @@ type LogConfig struct {
|
||||
Output string `json:"output" yaml:"output"` // output location. (eg. "stdout" or "path/to/file.log")
|
||||
NoColors bool `json:"noColors" yaml:"noColors"` // disable colors in the output
|
||||
Json bool `json:"json" yaml:"json"` // output in json format
|
||||
Utc bool `json:"utc" yaml:"utc"` // use UTC time in the output instead of local time
|
||||
Utc bool `json:"utc" yaml:"utc"` // use UTC time in the output instead of local time.
|
||||
ApiFilter string `json:"apiFilter" yaml:"apiFilter"` // regex filter that excludes matching full api paths from being logged. (eg. '/user\?id\=self') Defaults to '^/health|^/favicon.ico|^/static|^/public/static'
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
|
||||
Name string `json:"name"` // display name
|
||||
@@ -195,9 +211,9 @@ type Source struct {
|
||||
type SourceConfig struct {
|
||||
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
|
||||
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
|
||||
ReadOnly bool `json:"readOnly,omitempty"` // read-only source, changes from the UI, webdav, and API will be disabled.
|
||||
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
|
||||
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // deprecated: create a rule with indexingIntervalMinutes to set the indexing interval for this source
|
||||
DisableIndexing bool `json:"disableIndexing,omitempty"` // deprecated: use indexingDisabled instead to disable the indexing of this source
|
||||
Conditionals ConditionalFilter `json:"conditionals"` // deprecated: use source.rules instead
|
||||
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
|
||||
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
|
||||
@@ -260,6 +276,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 {
|
||||
@@ -272,6 +289,7 @@ type Frontend struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -298,22 +316,99 @@ type ExternalLink struct {
|
||||
Url string `json:"url" validate:"required"` // the url to link to
|
||||
}
|
||||
|
||||
// UserDefaultsPreview holds preview settings with pointer types for defaults
|
||||
type UserDefaultsPreview struct {
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
|
||||
HighQuality *bool `json:"highQuality"` // use high quality thumbnails
|
||||
Image *bool `json:"image"` // show thumbnails for image files
|
||||
Video *bool `json:"video"` // show thumbnails for video 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
|
||||
}
|
||||
|
||||
// UserDefaultsPermissions holds permission settings with pointer types for defaults
|
||||
type UserDefaultsPermissions struct {
|
||||
Api bool `json:"api"` // deprecated: use account.permissions.api instead. allow api access
|
||||
Admin bool `json:"admin"` // deprecated: use account.permissions.admin instead. allow admin access
|
||||
Modify bool `json:"modify"` // deprecated: use account.permissions.modify instead. allow modifying files
|
||||
Share bool `json:"share"` // deprecated: use account.permissions.share instead. allow sharing files
|
||||
Realtime bool `json:"realtime"` // deprecated: use account.permissions.realtime instead. allow realtime updates
|
||||
Delete bool `json:"delete"` // deprecated: use account.permissions.delete instead. allow deleting files
|
||||
Create bool `json:"create"` // deprecated: use account.permissions.create instead. allow creating or uploading files
|
||||
Download *bool `json:"download"` // deprecated: use account.permissions.download instead. allow downloading files
|
||||
}
|
||||
|
||||
// New organized structures for UserDefaults
|
||||
|
||||
// UserDefaultsSidebar holds sidebar-related settings
|
||||
type UserDefaultsSidebar struct {
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
HideFileActions bool `json:"hideFileActions"` // hide the file actions in the sidebar
|
||||
DisableHideOnPreview bool `json:"disableHideOnPreview"` // keep sidebar open when previewing files (was preview.disableHideSidebar)
|
||||
Sticky bool `json:"sticky"` // keep sidebar open when navigating
|
||||
HideFiles bool `json:"hideFiles"` // hide files in the sidebar tree navigation, when true, will show only directories
|
||||
ShowTools *bool `json:"showTools"` // show sidebar links with category "tool"; default is true
|
||||
}
|
||||
|
||||
// UserDefaultsListing holds file listing display settings
|
||||
type UserDefaultsListing struct {
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI
|
||||
ShowCopyPath bool `json:"showCopyPath"` // show copy path button in the context menu
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
}
|
||||
|
||||
// UserDefaultsPreview holds preview-related settings
|
||||
type UserDefaultsPreview struct {
|
||||
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
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // comma separated list of file extensions to disable preview for
|
||||
HighQuality *bool `json:"highQuality"` // high quality preview thumbnails
|
||||
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
|
||||
Models *bool `json:"models"` // show live thumbnails for 3D models files
|
||||
// deprecated fields
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // deprecated: use sidebar.disableHideOnPreview instead. disable the hide sidebar preview for previews and editors
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // deprecated: use fileViewer.defaultMediaPlayer instead. disable the styled feature-rich media player for browser default
|
||||
AutoplayMedia bool `json:"autoplayMedia"` // deprecated: use fileViewer.autoplayMedia instead. autoplay media files in preview
|
||||
}
|
||||
|
||||
// UserDefaultsFileViewer holds file viewer/editor settings
|
||||
type UserDefaultsFileViewer struct {
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DisableViewingExt string `json:"disableViewingExt"` // comma separated list of file extensions to disable viewing for
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
|
||||
}
|
||||
|
||||
// UserDefaultsSearch holds search-related settings
|
||||
type UserDefaultsSearch struct {
|
||||
DisableOptions bool `json:"disableOptions"` // disable the search options in the search bar
|
||||
}
|
||||
|
||||
// UserDefaultsUI holds UI/appearance settings
|
||||
type UserDefaultsUI struct {
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
|
||||
}
|
||||
|
||||
// UserDefaultsAccount holds account/security settings
|
||||
type UserDefaultsAccount struct {
|
||||
Permissions UserDefaultsAccountPermissions `json:"permissions"`
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings"` // disable the user from viewing the settings page
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
}
|
||||
|
||||
// UserDefaultsAccountPermissions holds permission settings
|
||||
type UserDefaultsAccountPermissions struct {
|
||||
Api bool `json:"api"` // allow api access
|
||||
Admin bool `json:"admin"` // allow admin access
|
||||
Modify bool `json:"modify"` // allow modifying files
|
||||
@@ -324,37 +419,51 @@ type UserDefaultsPermissions struct {
|
||||
Download *bool `json:"download"` // allow downloading files
|
||||
}
|
||||
|
||||
// UserDefaults is a type that holds the default values
|
||||
// for some fields on User.
|
||||
// UserDefaults is a type that holds the default values for some fields on User.
|
||||
type UserDefaults struct {
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
|
||||
Preview UserDefaultsPreview `json:"preview"`
|
||||
// New organized structure
|
||||
Sidebar UserDefaultsSidebar `json:"sidebar,omitempty"`
|
||||
Listing UserDefaultsListing `json:"listing,omitempty"`
|
||||
Preview UserDefaultsPreview `json:"preview,omitempty"`
|
||||
FileViewer UserDefaultsFileViewer `json:"fileViewer,omitempty"`
|
||||
Search UserDefaultsSearch `json:"search,omitempty"`
|
||||
UI UserDefaultsUI `json:"ui,omitempty"`
|
||||
FileLoading users.FileLoading `json:"fileLoading,omitempty"`
|
||||
Account UserDefaultsAccount `json:"account,omitempty"`
|
||||
|
||||
// Deprecated fields - kept for backwards compatibility
|
||||
EditorQuickSave bool `json:"editorQuickSave,omitempty"` // deprecated: use fileViewer.editorQuickSave instead
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions,omitempty"` // deprecated: use sidebar.hideSidebarFileActions instead
|
||||
DisableQuickToggles bool `json:"disableQuickToggles,omitempty"` // deprecated: use sidebar.disableQuickToggles instead
|
||||
DisableSearchOptions bool `json:"disableSearchOptions,omitempty"` // deprecated: use search.disableOptions instead
|
||||
StickySidebar bool `json:"stickySidebar,omitempty"` // deprecated: use sidebar.stickySidebar instead
|
||||
HideFilesInTree bool `json:"hideFilesInTree,omitempty"` // deprecated: use sidebar.hideFilesInTree instead
|
||||
DarkMode *bool `json:"darkMode,omitempty"` // deprecated: use sidebar.darkMode instead
|
||||
Locale string `json:"locale,omitempty"` // deprecated: use ui.locale instead
|
||||
ViewMode string `json:"viewMode,omitempty"` // deprecated: use sidebar.viewMode instead
|
||||
SingleClick bool `json:"singleClick,omitempty"` // deprecated: use sidebar.singleClick instead
|
||||
ShowHidden bool `json:"showHidden,omitempty"` // deprecated: use listing.showHidden instead
|
||||
HideFileExt string `json:"hideFileExt,omitempty"` // deprecated: use listing.hideFileExt instead
|
||||
DateFormat bool `json:"dateFormat,omitempty"` // deprecated: use listing.dateFormat instead
|
||||
GallerySize int `json:"gallerySize,omitempty"` // deprecated: use sidebar.gallerySize instead
|
||||
ThemeColor string `json:"themeColor,omitempty"` // deprecated: use ui.themeColor instead
|
||||
QuickDownload bool `json:"quickDownload,omitempty"` // deprecated: use listing.quickDownload instead
|
||||
DisablePreviewExt string `json:"disablePreviewExt,omitempty"` // deprecated: use preview.disablePreviewExt instead
|
||||
DisableViewingExt string `json:"disableViewingExt,omitempty"` // deprecated: use fileViewer.disableViewingExt instead
|
||||
LockPassword bool `json:"lockPassword,omitempty"` // deprecated: use account.lockPassword instead
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // deprecated: use account.disableSettings instead
|
||||
DefaultScopes []users.SourceScope `json:"-"`
|
||||
Permissions UserDefaultsPermissions `json:"permissions"`
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
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
|
||||
Permissions UserDefaultsPermissions `json:"permissions,omitempty"` // deprecated: use account.permissions instead
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // deprecated: use account.loginMethod instead
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications,omitempty"` // deprecated: use account.disableUpdateNotifications instead
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming,omitempty"` // deprecated: use listing.deleteWithoutConfirming instead
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive,omitempty"` // deprecated: use listing.deleteAfterArchive instead
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt,omitempty"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt,omitempty"` // deprecated: use fileViewer.disableOnlyOfficeExt instead
|
||||
CustomTheme string `json:"customTheme,omitempty"` // deprecated: use ui.customTheme instead
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple,omitempty"` // deprecated: use listing.showSelectMultiple instead
|
||||
ShowToolsInSidebar *bool `json:"showToolsInSidebar,omitempty"` // deprecated: use sidebar.showToolsInSidebar instead
|
||||
DebugOffice bool `json:"debugOffice,omitempty"` // deprecated: use fileViewer.debugOffice instead
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown,omitempty"` // deprecated: use fileViewer.preferEditorForMarkdown instead
|
||||
ShowCopyPath bool `json:"showCopyPath,omitempty"` // deprecated: use listing.showCopyPath instead
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func randomBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// RandomHex returns a hex-encoded string from byteLen bytes of crypto/rand (length 2*byteLen).
|
||||
func RandomHex(byteLen int) (string, error) {
|
||||
b, err := randomBytes(byteLen)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// CSPNonce returns a base64-encoded random value suitable for Content-Security-Policy nonces
|
||||
// and matching HTML nonce="" attributes (cryptographically random, URL/header safe characters).
|
||||
func CSPNonce() (string, error) {
|
||||
b, err := randomBytes(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -52,12 +52,16 @@ 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)
|
||||
HideFileExt string // Hide files based on extensions (eg: '.lrc' or '.srt')
|
||||
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.
|
||||
@@ -77,5 +81,31 @@ func SanitizeUserPath(userPath string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if clean == "." {
|
||||
return "", fmt.Errorf("invalid path: path must standard index path")
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// HideFileByExt will check if a filename matches any of the hidden extensions
|
||||
func HideFileByExt(filename, hideExt string) bool {
|
||||
if hideExt == "" {
|
||||
return false
|
||||
}
|
||||
exts := strings.Fields(hideExt)
|
||||
nameLower := strings.ToLower(filename)
|
||||
for _, ext := range exts {
|
||||
ext = strings.TrimSpace(ext)
|
||||
if ext == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(ext, ".") {
|
||||
ext = "." + ext
|
||||
}
|
||||
if strings.HasSuffix(nameLower, strings.ToLower(ext)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|warning|error"
|
||||
sources:
|
||||
- path: "/srv"
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
highQuality: true
|
||||
audio: true
|
||||
motionVideoPreview: true
|
||||
office: true
|
||||
popup: true
|
||||
folder: true
|
||||
models: true
|
||||
@@ -28,7 +28,6 @@ userDefaults:
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
preview:
|
||||
disableHideSidebar: true # disable the hide sidebar preview for previews and editors
|
||||
highQuality: true # generate high quality thumbnail preview images
|
||||
image: true # show thumbnail preview image for image files
|
||||
video: true # show thumbnail preview image for video files
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
|
||||
+73
-36
@@ -5,47 +5,84 @@ server:
|
||||
- levels: "info|warning|error"
|
||||
sources:
|
||||
- path: "/srv"
|
||||
auth:
|
||||
key: "change-me-to-a-random-string"
|
||||
totpSecret: "change-me-to-another-random-string"
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
minLength: 5
|
||||
signup: false
|
||||
passkey:
|
||||
enabled: false
|
||||
userDefaults:
|
||||
editorQuickSave: false # show quick save button in editor
|
||||
hideSidebarFileActions: false # hide the file actions in the sidebar
|
||||
disableQuickToggles: false # disable the quick toggles in the sidebar
|
||||
disableSearchOptions: false # disable the search options in the search bar
|
||||
stickySidebar: true # keep sidebar open when navigating
|
||||
darkMode: true # should dark mode be enabled (defaults to true if not set)
|
||||
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
|
||||
singleClick: false # open directory on single click, also enables middle click to open in new tab
|
||||
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
|
||||
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
|
||||
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
quickDownload: false # show icon to download in one click
|
||||
disablePreviewExt: "" # space separated list of file extensions to disable preview for
|
||||
disableViewingExt: "" # space separated list of file extensions to disable viewing for
|
||||
lockPassword: false # disable the user from changing their password
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
sidebar:
|
||||
disableQuickToggles: false # disable the quick toggles in the sidebar
|
||||
hideFileActions: false # hide the file actions in the sidebar
|
||||
disableHideOnPreview: false # keep sidebar open when previewing files
|
||||
sticky: true # keep sidebar open when navigating
|
||||
hideFiles: false # hide files in the sidebar tree navigation, when true, will show only directories
|
||||
showTools: true # show sidebar links of category "tool"; when false they stay in the list but are hidden in the UI
|
||||
|
||||
listing:
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
|
||||
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
quickDownload: false # show icon to download in one click
|
||||
showSelectMultiple: false # show select multiple files on desktop
|
||||
singleClick: false # open directory on single click, also enables middle click to open in new tab
|
||||
hideFileExt: "" # space separated list of file extensions to hide in UI
|
||||
showCopyPath: false # show copy path button in the context menu
|
||||
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
|
||||
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
|
||||
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
|
||||
|
||||
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
|
||||
disablePreviewExt: "" # space separated list of file extensions to disable preview for
|
||||
highQuality: true # high quality preview thumbnails
|
||||
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
admin: false # allow admin access
|
||||
modify: false # allow modifying files
|
||||
share: false # allow sharing files
|
||||
realtime: false # allow realtime updates
|
||||
delete: false # allow deleting files
|
||||
create: false # allow creating or uploading files
|
||||
download: true # allow downloading files (defaults to true if not set)
|
||||
disableUpdateNotifications: false # disable update notifications banner for admin users
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
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
|
||||
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
|
||||
|
||||
fileViewer:
|
||||
editorQuickSave: false # show quick save button in editor
|
||||
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
|
||||
disableViewingExt: "" # space separated list of file extensions to disable viewing for
|
||||
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
|
||||
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
defaultMediaPlayer: false # disable html5 media player and use the default media player
|
||||
|
||||
search:
|
||||
disableOptions: false # disable the search options in the search bar
|
||||
|
||||
ui:
|
||||
darkMode: true # should dark mode be enabled (defaults to true if not set)
|
||||
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config
|
||||
locale: "en" # language to use: eg. de, en, or fr
|
||||
|
||||
fileLoading:
|
||||
maxConcurrentUpload: 10 # maximum number of concurrent uploads
|
||||
uploadChunkSizeMb: 10 # upload chunk size in MB
|
||||
downloadChunkSizeMb: 0 # download chunk size in MB (0 = no chunking)
|
||||
|
||||
account:
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
admin: false # allow admin access
|
||||
modify: false # allow modifying files
|
||||
share: false # allow sharing files
|
||||
realtime: false # allow realtime updates
|
||||
delete: false # allow deleting files
|
||||
create: false # allow creating or uploading files
|
||||
download: true # allow downloading files (defaults to true if not set)
|
||||
lockPassword: false # disable the user from changing their password
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
loginMethod: "password" # login method to use: eg. password, proxy, oidc
|
||||
disableUpdateNotifications: false # disable update notifications banner for admin users
|
||||
@@ -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))
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -31,4 +36,5 @@ type PersistedScannerInfo struct {
|
||||
QuickScanTime int `json:"quickScanTime"`
|
||||
FullScanTime int `json:"fullScanTime"`
|
||||
LastScanned time.Time `json:"lastScanned"`
|
||||
FullScanCounter int `json:"fullScanCounter"` // 0-4: position in 1 full + 4 quick cycle before next executeScan
|
||||
}
|
||||
@@ -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,11 @@ 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)
|
||||
HideFileExt string `json:"hideFileExt,omitempty"` // show hidden files based on extensions in shares
|
||||
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
-135
@@ -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
|
||||
@@ -255,14 +352,7 @@ 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()
|
||||
s.setCacheAfterMove(l, oldSource, oldPath)
|
||||
updated++
|
||||
}
|
||||
return updated, nil
|
||||
@@ -283,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
|
||||
@@ -298,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
|
||||
}
|
||||
|
||||
@@ -309,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)
|
||||
|
||||
+154
-101
@@ -2,6 +2,7 @@ package sql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -90,7 +91,8 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
|
||||
// For persistent databases, check integrity on startup
|
||||
// If corrupted, delete and recreate the database
|
||||
if !disableReuse {
|
||||
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
|
||||
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
|
||||
@@ -112,10 +114,73 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
|
||||
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)
|
||||
@@ -233,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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -271,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 {
|
||||
@@ -298,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)
|
||||
@@ -409,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 := `
|
||||
@@ -541,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()
|
||||
}
|
||||
@@ -1012,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
|
||||
@@ -1198,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.
|
||||
@@ -1272,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()
|
||||
}
|
||||
@@ -18,9 +18,27 @@ type SearchItem struct {
|
||||
HasPreview bool
|
||||
}
|
||||
|
||||
// appendNameGlobSQL adds AND (name GLOB ? OR ...) or AND (name GLOB ? AND ...) when joinAnd is true.
|
||||
func appendNameGlobSQL(query string, args []interface{}, patterns []string, joinAnd bool) (string, []interface{}) {
|
||||
if len(patterns) == 0 {
|
||||
return query, args
|
||||
}
|
||||
parts := make([]string, len(patterns))
|
||||
for i := range patterns {
|
||||
parts[i] = "name GLOB ?"
|
||||
args = append(args, patterns[i])
|
||||
}
|
||||
join := " OR "
|
||||
if joinAnd {
|
||||
join = " AND "
|
||||
}
|
||||
return query + " AND (" + strings.Join(parts, join) + ")", args
|
||||
}
|
||||
|
||||
// SearchItems queries the database for items matching the search criteria for a single source.
|
||||
// When nameGlobPatterns is non-empty (and largest is false), rows are restricted with SQLite name GLOB ... OR ....
|
||||
// Returns rows that can be iterated to scan search results.
|
||||
func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.Rows, error) {
|
||||
func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd bool) (*sql.Rows, error) {
|
||||
query := `
|
||||
SELECT path, name, size, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
@@ -35,6 +53,10 @@ func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.
|
||||
args = append(args, scope+"*")
|
||||
}
|
||||
|
||||
if !largest && len(nameGlobPatterns) > 0 {
|
||||
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd)
|
||||
}
|
||||
|
||||
if largest {
|
||||
query += " ORDER BY size DESC"
|
||||
}
|
||||
@@ -43,8 +65,9 @@ func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.
|
||||
}
|
||||
|
||||
// SearchItemsMultiSource queries the database for items matching the search criteria across multiple sources.
|
||||
// When nameGlobPatterns is non-empty (and largest is false), restricts rows with SQLite name GLOB ... OR ....
|
||||
// Returns rows that can be iterated to scan search results.
|
||||
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool) (*sql.Rows, error) {
|
||||
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd bool) (*sql.Rows, error) {
|
||||
if len(sources) == 0 {
|
||||
return nil, fmt.Errorf("at least one source is required")
|
||||
}
|
||||
@@ -95,6 +118,10 @@ func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[str
|
||||
query += " WHERE " + strings.Join(whereClauses, " AND ")
|
||||
}
|
||||
|
||||
if !largest && len(nameGlobPatterns) > 0 {
|
||||
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd)
|
||||
}
|
||||
|
||||
if largest {
|
||||
query += " ORDER BY size DESC"
|
||||
}
|
||||
|
||||
@@ -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 && !existingUser.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())
|
||||
}
|
||||
@@ -247,7 +251,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
if len(fields) == 0 || fields[0] == "all" {
|
||||
fields = []string{}
|
||||
v := reflect.ValueOf(user)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.Kind() == reflect.Pointer {
|
||||
v = v.Elem()
|
||||
}
|
||||
t := v.Type()
|
||||
@@ -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
|
||||
}
|
||||
@@ -266,7 +270,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
if field.Anonymous {
|
||||
// Get the embedded struct type
|
||||
embeddedType := field.Type
|
||||
if embeddedType.Kind() == reflect.Ptr {
|
||||
if embeddedType.Kind() == reflect.Pointer {
|
||||
embeddedType = embeddedType.Elem()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -89,16 +92,10 @@ func quickSetup(store *bolt.BoltStore) {
|
||||
settings.Config.Auth.AdminPassword = "admin"
|
||||
}
|
||||
user.Password = settings.Config.Auth.AdminPassword
|
||||
user.Permissions.Admin = true
|
||||
user.Scopes = []users.SourceScope{}
|
||||
for _, val := range settings.Config.Server.Sources {
|
||||
user.Scopes = append(user.Scopes, users.SourceScope{
|
||||
Name: val.Path, // backend name is path
|
||||
Scope: "",
|
||||
})
|
||||
}
|
||||
user.LockPassword = false
|
||||
download := user.Permissions.Download
|
||||
user.Permissions = settings.AdminPerms()
|
||||
user.Permissions.Download = download
|
||||
user.ShowFirstLogin = settings.Env.IsFirstLoad && user.Permissions.Admin
|
||||
logger.Debugf("Creating user as admin: %v %v", user.Username, user.Password)
|
||||
err = store.Users.Save(user, true, true)
|
||||
@@ -115,7 +112,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
|
||||
}
|
||||
@@ -127,6 +124,7 @@ func CreateUser(userInfo users.User, permissions users.Permissions) error {
|
||||
}
|
||||
settings.ApplyUserDefaults(newUser)
|
||||
newUser.Permissions = permissions
|
||||
newUser.Version = users.CurrentUserMigrationVersion
|
||||
logger.Debugf("Creating user: %v %v", userInfo.Username, userInfo.Scopes)
|
||||
// create new home directories
|
||||
err := userStore.Save(newUser, true, false)
|
||||
|
||||
@@ -5,6 +5,23 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveSourceKey maps a string that is either:
|
||||
// - the configured source filesystem path (canonical Bolt / in-memory form for
|
||||
// SourceScope.Name and source-type SidebarLink.SourceName), or
|
||||
// - the source display name (JSON form from clients).
|
||||
//
|
||||
// Path is checked first so absolute paths are unambiguous. Used on write paths
|
||||
// (normalize to Path) and read paths (normalize API to display Name).
|
||||
func ResolveSourceKey(key string) (SourceInfo, bool) {
|
||||
if sourceConfig == nil || key == "" {
|
||||
return SourceInfo{}, false
|
||||
}
|
||||
if info, ok := sourceConfig.GetSourceByPath(key); ok {
|
||||
return info, true
|
||||
}
|
||||
return sourceConfig.GetSourceByName(key)
|
||||
}
|
||||
|
||||
// GetSourceNames returns all source names the user has access to (assumes backend-style scopes)
|
||||
func (u *User) GetSourceNames() []string {
|
||||
if sourceConfig == nil {
|
||||
@@ -24,10 +41,9 @@ func (u *User) GetSourceNames() []string {
|
||||
return sources
|
||||
}
|
||||
|
||||
// GetBackendScopes converts the user's scopes from frontend-style to backend-style
|
||||
// GetBackendScopes normalizes scopes for Bolt: SourceScope.Name is always the source filesystem path.
|
||||
// Incoming rows (API) may use display name or path; legacy rows may use either.
|
||||
func (u *User) GetBackendScopes() ([]SourceScope, error) {
|
||||
// Only convert scopes if they are not empty
|
||||
// Empty scopes during update should remain empty (not filled with defaults)
|
||||
if len(u.Scopes) == 0 {
|
||||
return []SourceScope{}, nil
|
||||
}
|
||||
@@ -37,24 +53,8 @@ func (u *User) GetBackendScopes() ([]SourceScope, error) {
|
||||
|
||||
newScopes := []SourceScope{}
|
||||
for _, scope := range u.Scopes {
|
||||
// First check if its already a path name and keep it
|
||||
source, ok := sourceConfig.GetSourceByPath(scope.Name)
|
||||
if ok {
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.DefaultUserScope
|
||||
}
|
||||
scope.Scope = normalizeScope(scope.Scope)
|
||||
newScopes = append(newScopes, SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if its the name of a source and convert it to a path
|
||||
source, ok = sourceConfig.GetSourceByName(scope.Name)
|
||||
source, ok := ResolveSourceKey(scope.Name)
|
||||
if !ok {
|
||||
// source might no longer be configured
|
||||
continue
|
||||
}
|
||||
if scope.Scope == "" {
|
||||
@@ -62,15 +62,15 @@ func (u *User) GetBackendScopes() ([]SourceScope, error) {
|
||||
}
|
||||
scope.Scope = normalizeScope(scope.Scope)
|
||||
newScopes = append(newScopes, SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Name: source.Path,
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
return newScopes, nil
|
||||
}
|
||||
|
||||
// GetFrontendScopes converts the user's scopes from backend-style to frontend-style
|
||||
// Backend scopes use source paths, frontend scopes use source names
|
||||
// GetFrontendScopes converts scopes for JSON clients: SourceScope.Name is always the source display name.
|
||||
// Assumes Bolt stores paths (GetBackendScopes); unknown keys are omitted.
|
||||
func (u *User) GetFrontendScopes() []SourceScope {
|
||||
if sourceConfig == nil {
|
||||
return []SourceScope{}
|
||||
@@ -78,19 +78,19 @@ func (u *User) GetFrontendScopes() []SourceScope {
|
||||
|
||||
newScopes := []SourceScope{}
|
||||
for _, scope := range u.Scopes {
|
||||
if source, ok := sourceConfig.GetSourceByPath(scope.Name); ok {
|
||||
// Replace scope.Name with source.Name while keeping the same Scope value
|
||||
newScopes = append(newScopes, SourceScope{
|
||||
Name: source.Name,
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
source, ok := ResolveSourceKey(scope.Name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
newScopes = append(newScopes, SourceScope{
|
||||
Name: source.Name,
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
return newScopes
|
||||
}
|
||||
|
||||
// GetBackendSidebarLinks converts the user's sidebar links from frontend-style to backend-style
|
||||
// Validates that sources exist and converts source names to paths
|
||||
// GetBackendSidebarLinks normalizes source links for Bolt: SourceName is the filesystem path.
|
||||
func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
|
||||
if sourceConfig == nil {
|
||||
return nil, fmt.Errorf("source config not initialized")
|
||||
@@ -98,52 +98,27 @@ 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)
|
||||
}
|
||||
// Validate source exists
|
||||
sourceInfo, ok := sourceConfig.GetSourceByName(link.SourceName)
|
||||
if !ok {
|
||||
sourceInfo2, ok2 := sourceConfig.GetSourceByPath(link.SourceName)
|
||||
if !ok && !ok2 {
|
||||
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
|
||||
}
|
||||
link.SourceName = sourceInfo.Path // if name changes keep link alive
|
||||
if ok {
|
||||
link.SourceName = sourceInfo.Path
|
||||
} else {
|
||||
link.SourceName = sourceInfo2.Path
|
||||
}
|
||||
}
|
||||
// Store the link as-is with all fields preserved
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks, nil
|
||||
}
|
||||
|
||||
// GetFrontendSidebarLinks converts the user's sidebar links from backend-style to frontend-style
|
||||
// Converts source paths to source names for the frontend
|
||||
func (u *User) GetFrontendSidebarLinks() []SidebarLink {
|
||||
if sourceConfig == nil {
|
||||
return []SidebarLink{}
|
||||
}
|
||||
|
||||
newLinks := []SidebarLink{}
|
||||
for _, link := range u.SidebarLinks {
|
||||
// For source links, validate that the source still exists
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
continue
|
||||
}
|
||||
// Check if source exists
|
||||
sourceInfo, ok := sourceConfig.GetSourceByPath(link.SourceName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
link.SourceName = sourceInfo.Name
|
||||
}
|
||||
// For share links, just pass through (shares are validated separately)
|
||||
// For all other links, pass through as-is
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks
|
||||
}
|
||||
|
||||
// normalizeScope ensures scope starts with / and doesn't end with / (except for root)
|
||||
func normalizeScope(scope string) string {
|
||||
if !strings.HasPrefix(scope, "/") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
const ()
|
||||
// CurrentUserMigrationVersion is persisted for newly created accounts and after legacy migrations finish.
|
||||
const CurrentUserMigrationVersion = 3
|
||||
|
||||
type LoginMethod string
|
||||
|
||||
@@ -15,13 +16,47 @@ const (
|
||||
LoginMethodPassword LoginMethod = "password"
|
||||
LoginMethodProxy LoginMethod = "proxy"
|
||||
LoginMethodOidc LoginMethod = "oidc"
|
||||
LoginMethodLdap LoginMethod = "ldap"
|
||||
LoginMethodJwt LoginMethod = "jwt"
|
||||
)
|
||||
|
||||
// WebAuthnCredentialFlags mirrors the boolean flags from authenticator data.
|
||||
type WebAuthnCredentialFlags struct {
|
||||
UserPresent bool `json:"userPresent"`
|
||||
UserVerified bool `json:"userVerified"`
|
||||
BackupEligible bool `json:"backupEligible"`
|
||||
BackupState bool `json:"backupState"`
|
||||
}
|
||||
|
||||
// WebAuthnCredential stores a single passkey credential in BoltDB-friendly format.
|
||||
type WebAuthnCredential struct {
|
||||
ID string `json:"id"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
AttestationType string `json:"attestationType"`
|
||||
AttestationFormat string `json:"attestationFormat"`
|
||||
Transport []string `json:"transport"`
|
||||
Flags WebAuthnCredentialFlags `json:"flags"`
|
||||
AAGUID string `json:"aaguid"`
|
||||
SignCount uint32 `json:"signCount"`
|
||||
CloneWarning bool `json:"cloneWarning"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
LastUsedAt int64 `json:"lastUsedAt"`
|
||||
ClientDataJSON string `json:"clientDataJSON"`
|
||||
ClientDataHash string `json:"clientDataHash"`
|
||||
AuthenticatorData string `json:"authenticatorData"`
|
||||
PublicKeyAlg int64 `json:"publicKeyAlg"`
|
||||
AttestationObj string `json:"attestationObj"`
|
||||
}
|
||||
|
||||
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,41 +84,43 @@ 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.
|
||||
// User describes a persisted account. Scopes and source sidebar links store filesystem paths in Name/SourceName;
|
||||
// JSON responses use display names only (see http prepForFrontend).
|
||||
type User struct {
|
||||
NonAdminEditable
|
||||
DisableSettings bool `json:"disableSettings"`
|
||||
ID uint `storm:"id,increment" json:"id"`
|
||||
Username string `storm:"unique" json:"username"`
|
||||
Scopes []SourceScope `json:"scopes"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
Permissions Permissions `json:"permissions"`
|
||||
ApiKeys map[string]AuthToken `json:"apiKeys,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
|
||||
DisableSettings bool `json:"disableSettings"`
|
||||
ID uint `storm:"id,increment" json:"id"`
|
||||
Username string `storm:"unique" json:"username"`
|
||||
Scopes []SourceScope `json:"scopes"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
Permissions Permissions `json:"permissions"`
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"` // Bolt: filesystem path; JSON API: display name after prepForFrontend
|
||||
Scope string `json:"scope"` // index path within that source
|
||||
}
|
||||
|
||||
// json tags must match variable name with smaller case first letter
|
||||
@@ -102,6 +139,7 @@ type NonAdminEditable struct {
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
Sorting Sorting `json:"sorting"`
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
@@ -114,9 +152,16 @@ 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
|
||||
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
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"`
|
||||
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
@@ -129,10 +174,10 @@ 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
|
||||
SourceName string `json:"sourceName,omitempty"` // Bolt: filesystem path. JSON out: display name (after prepForFrontend).
|
||||
}
|
||||
|
||||
func CleanUsername(s string) string {
|
||||
@@ -179,6 +224,11 @@ func SetSourceConfig(config *SourceConfigProvider) {
|
||||
sourceConfig = config
|
||||
}
|
||||
|
||||
// SourceConfigLoaded reports whether SetSourceConfig has been called (needed for ResolveSourceKey).
|
||||
func SourceConfigLoaded() bool {
|
||||
return sourceConfig != nil
|
||||
}
|
||||
|
||||
// GetScopeForSourcePath returns the scope for a given source path (backend-style)
|
||||
// This method works with backend-style scopes where Name is the source path
|
||||
func (u *User) GetScopeForSourcePath(sourcePath string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// WebAuthnID returns the user handle used by WebAuthn.
|
||||
func (u *User) WebAuthnID() []byte {
|
||||
return []byte(fmt.Sprintf("%d", u.ID))
|
||||
}
|
||||
|
||||
// WebAuthnName returns the human-readable name for the user.
|
||||
func (u *User) WebAuthnName() string {
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// WebAuthnDisplayName returns the display name for the user.
|
||||
func (u *User) WebAuthnDisplayName() string {
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// WebAuthnCredentials returns the stored credentials converted to the library's type.
|
||||
func (u *User) WebAuthnCredentials() []webauthn.Credential {
|
||||
creds := make([]webauthn.Credential, len(u.PasskeyCredentials))
|
||||
for i, c := range u.PasskeyCredentials {
|
||||
creds[i] = *credentialToLibrary(&c)
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
// HasPasskeyMFA returns true if the user has at least one passkey credential configured.
|
||||
func (u *User) HasPasskeyMFA() bool {
|
||||
return len(u.PasskeyCredentials) > 0
|
||||
}
|
||||
|
||||
// credentialToLibrary converts our BoltDB-safe WebAuthnCredential to the library's Credential.
|
||||
func credentialToLibrary(c *WebAuthnCredential) *webauthn.Credential {
|
||||
id, _ := base64.RawURLEncoding.DecodeString(c.ID)
|
||||
pubKey, _ := base64.RawURLEncoding.DecodeString(c.PublicKey)
|
||||
aaguid, _ := base64.RawURLEncoding.DecodeString(c.AAGUID)
|
||||
cdj, _ := base64.RawURLEncoding.DecodeString(c.ClientDataJSON)
|
||||
cdh, _ := base64.RawURLEncoding.DecodeString(c.ClientDataHash)
|
||||
authData, _ := base64.RawURLEncoding.DecodeString(c.AuthenticatorData)
|
||||
attObj, _ := base64.RawURLEncoding.DecodeString(c.AttestationObj)
|
||||
|
||||
transports := make([]protocol.AuthenticatorTransport, len(c.Transport))
|
||||
for i, t := range c.Transport {
|
||||
transports[i] = protocol.AuthenticatorTransport(t)
|
||||
}
|
||||
|
||||
return &webauthn.Credential{
|
||||
ID: id,
|
||||
PublicKey: pubKey,
|
||||
AttestationType: c.AttestationType,
|
||||
AttestationFormat: c.AttestationFormat,
|
||||
Transport: transports,
|
||||
Flags: webauthn.CredentialFlags{
|
||||
UserPresent: c.Flags.UserPresent,
|
||||
UserVerified: c.Flags.UserVerified,
|
||||
BackupEligible: c.Flags.BackupEligible,
|
||||
BackupState: c.Flags.BackupState,
|
||||
},
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: aaguid,
|
||||
SignCount: c.SignCount,
|
||||
CloneWarning: c.CloneWarning,
|
||||
},
|
||||
Attestation: webauthn.CredentialAttestation{
|
||||
ClientDataJSON: cdj,
|
||||
ClientDataHash: cdh,
|
||||
AuthenticatorData: authData,
|
||||
PublicKeyAlgorithm: c.PublicKeyAlg,
|
||||
Object: attObj,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CredentialFromLibrary converts a library Credential to our BoltDB-safe format.
|
||||
func CredentialFromLibrary(name string, cred *webauthn.Credential) WebAuthnCredential {
|
||||
transports := make([]string, len(cred.Transport))
|
||||
for i, t := range cred.Transport {
|
||||
transports[i] = string(t)
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
|
||||
return WebAuthnCredential{
|
||||
ID: base64.RawURLEncoding.EncodeToString(cred.ID),
|
||||
PublicKey: base64.RawURLEncoding.EncodeToString(cred.PublicKey),
|
||||
AttestationType: cred.AttestationType,
|
||||
AttestationFormat: cred.AttestationFormat,
|
||||
Transport: transports,
|
||||
Flags: WebAuthnCredentialFlags{
|
||||
UserPresent: cred.Flags.UserPresent,
|
||||
UserVerified: cred.Flags.UserVerified,
|
||||
BackupEligible: cred.Flags.BackupEligible,
|
||||
BackupState: cred.Flags.BackupState,
|
||||
},
|
||||
AAGUID: base64.RawURLEncoding.EncodeToString(cred.Authenticator.AAGUID),
|
||||
SignCount: cred.Authenticator.SignCount,
|
||||
CloneWarning: cred.Authenticator.CloneWarning,
|
||||
Name: name,
|
||||
CreatedAt: now,
|
||||
LastUsedAt: now,
|
||||
ClientDataJSON: base64.RawURLEncoding.EncodeToString(cred.Attestation.ClientDataJSON),
|
||||
ClientDataHash: base64.RawURLEncoding.EncodeToString(cred.Attestation.ClientDataHash),
|
||||
AuthenticatorData: base64.RawURLEncoding.EncodeToString(cred.Attestation.AuthenticatorData),
|
||||
PublicKeyAlg: cred.Attestation.PublicKeyAlgorithm,
|
||||
AttestationObj: base64.RawURLEncoding.EncodeToString(cred.Attestation.Object),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCredentialFromLibrary updates an existing WebAuthnCredential from the library's Credential
|
||||
// (used after login to update sign count, flags, and last used time).
|
||||
func UpdateCredentialFromLibrary(c *WebAuthnCredential, cred *webauthn.Credential) {
|
||||
c.SignCount = cred.Authenticator.SignCount
|
||||
c.Flags.UserPresent = cred.Flags.UserPresent
|
||||
c.Flags.UserVerified = cred.Flags.UserVerified
|
||||
c.Flags.BackupEligible = cred.Flags.BackupEligible
|
||||
c.Flags.BackupState = cred.Flags.BackupState
|
||||
c.LastUsedAt = time.Now().Unix()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+99
-83
@@ -4,38 +4,42 @@ 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-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.30.1
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/go-webauthn/webauthn v0.17.3
|
||||
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/kovidgoyal/imaging v1.8.19
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/kovidgoyal/imaging v1.8.21
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
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.47.0
|
||||
golang.org/x/image v0.35.0
|
||||
golang.org/x/mod v0.32.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/image v0.40.0
|
||||
golang.org/x/mod v0.36.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.44.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.45.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.44.3
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.3 // indirect
|
||||
codeberg.org/chavacava/garif v0.2.0 // indirect
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
@@ -48,77 +52,84 @@ 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/ClickHouse/clickhouse-go-linter v1.2.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/Masterminds/semver/v3 v3.5.0 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.64.2 // 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.24.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/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
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
|
||||
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||
github.com/bep/golibsass v1.2.0 // indirect
|
||||
github.com/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.8.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
|
||||
github.com/butuzov/ireturn v0.4.0 // indirect
|
||||
github.com/butuzov/ireturn v0.4.1 // indirect
|
||||
github.com/butuzov/mirror v1.3.0 // indirect
|
||||
github.com/catenacyber/perfsprint v0.10.1 // indirect
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charithe/durationcheck v0.0.11 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.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/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/ckaznocha/intrange v0.3.1 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/curioswitch/go-reassign v0.3.0 // indirect
|
||||
github.com/daixiang0/gci v0.13.7 // indirect
|
||||
github.com/dave/dst v0.27.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/denis-tingaikin/go-header v0.5.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
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/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/fzipp/gocyclo v0.6.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.18 // 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.3 // 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,25 +139,29 @@ 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-webauthn/x v0.2.5 // 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-20260129164528-943061e2742c // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6 // 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/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-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.12.2 // 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/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gordonklaus/ineffassign v0.2.0 // indirect
|
||||
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
|
||||
@@ -154,19 +169,18 @@ require (
|
||||
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
|
||||
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hexops/gotextdiff v1.0.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jgautheron/goconst v1.8.2 // indirect
|
||||
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
|
||||
github.com/jgautheron/goconst v1.10.0 // indirect
|
||||
github.com/jjti/go-spancheck v0.6.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/jupiterrider/ffi v0.5.1 // indirect
|
||||
github.com/jupiterrider/ffi v0.7.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
|
||||
@@ -181,31 +195,32 @@ require (
|
||||
github.com/ldez/usetesting v0.5.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/leonklingele/grouper v1.1.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/macabu/inamedparam v0.2.0 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
|
||||
github.com/manuelarte/funcorder v0.5.0 // indirect
|
||||
github.com/manuelarte/funcorder v0.6.0 // indirect
|
||||
github.com/maratori/testableexamples v1.0.1 // indirect
|
||||
github.com/maratori/testpackage v1.1.2 // indirect
|
||||
github.com/matoous/godox v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // 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
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/nakabonne/nestif v0.3.1 // indirect
|
||||
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/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.12.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
@@ -223,16 +238,17 @@ 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/ryancurrah/gomodguard/v2 v2.1.3 // 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.26.1 // 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/sourcegraph/go-diff v0.7.0 // indirect
|
||||
github.com/sonatard/noctx v0.5.1 // indirect
|
||||
github.com/sourcegraph/go-diff v0.8.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
@@ -244,16 +260,18 @@ require (
|
||||
github.com/subosito/gotenv v1.4.1 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.3 // indirect
|
||||
github.com/tetafro/godot v1.5.4 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
|
||||
github.com/tetafro/godot v1.5.6 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
|
||||
github.com/timonwong/loggercheck v0.11.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
|
||||
github.com/ultraware/funlen v0.2.0 // indirect
|
||||
github.com/ultraware/whitespace v0.2.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.3.0 // indirect
|
||||
github.com/uudashr/gocognit v1.2.0 // indirect
|
||||
github.com/uudashr/iface v1.4.1 // indirect
|
||||
github.com/uudashr/gocognit v1.2.1 // indirect
|
||||
github.com/uudashr/iface v1.4.2 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xen0n/gosmopolitan v1.3.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yagipy/maintidx v1.0.0 // indirect
|
||||
@@ -261,24 +279,22 @@ require (
|
||||
github.com/ykadowak/zerologlint v0.1.5 // indirect
|
||||
gitlab.com/bosi/decorder v0.4.2 // indirect
|
||||
go-simpler.org/musttag v0.14.0 // indirect
|
||||
go-simpler.org/sloglint v0.11.1 // indirect
|
||||
go.augendre.info/arangolint v0.3.1 // indirect
|
||||
go-simpler.org/sloglint v0.12.0 // 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-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // 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.37.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // 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.7 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
|
||||
+240
-222
@@ -2,6 +2,8 @@
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY=
|
||||
4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU=
|
||||
4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
|
||||
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
|
||||
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
@@ -59,32 +61,36 @@ 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.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=
|
||||
github.com/ClickHouse/clickhouse-go-linter v1.2.0 h1:zbm174up3hTKjp0wKZVnTzRiG7tSF5XZF0FJG/MuCBI=
|
||||
github.com/ClickHouse/clickhouse-go-linter v1.2.0/go.mod h1:pLorS7ffPTfuUV9M0SJgfHA/h/WQPQUk2FWG9x74cQ4=
|
||||
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g=
|
||||
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
|
||||
github.com/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.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ=
|
||||
github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
|
||||
github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
||||
github.com/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.64.2 h1:H49xKRa4r3uFUycmCCA+JectVuZLNMPGdypLFpoHVcY=
|
||||
github.com/air-verse/air v1.64.2/go.mod h1:SOwv08NTvKextanAY819AK0FK6XzQO5/tC6s1R+rk8o=
|
||||
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.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||
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.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
@@ -94,26 +100,28 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
|
||||
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.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=
|
||||
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c=
|
||||
github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE=
|
||||
github.com/ashanbrown/makezero/v2 v2.1.0/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI=
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM=
|
||||
github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM=
|
||||
github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
@@ -154,8 +162,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.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs=
|
||||
github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o=
|
||||
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=
|
||||
@@ -163,8 +171,8 @@ github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
|
||||
github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
|
||||
github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
|
||||
github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
|
||||
github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E=
|
||||
github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70=
|
||||
github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I=
|
||||
github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4=
|
||||
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
|
||||
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
|
||||
github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
|
||||
@@ -178,16 +186,18 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk=
|
||||
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
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/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=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
@@ -196,9 +206,13 @@ github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nW
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/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-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=
|
||||
@@ -221,8 +235,8 @@ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4m
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
|
||||
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
|
||||
@@ -242,8 +256,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=
|
||||
@@ -252,8 +266,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=
|
||||
@@ -263,6 +277,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
@@ -273,8 +289,10 @@ github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz
|
||||
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.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo=
|
||||
github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
|
||||
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=
|
||||
@@ -286,51 +304,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.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
|
||||
github.com/go-openapi/spec v0.22.3/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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
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=
|
||||
@@ -355,8 +375,12 @@ 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-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
|
||||
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
|
||||
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
|
||||
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
|
||||
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=
|
||||
@@ -365,8 +389,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA=
|
||||
github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8=
|
||||
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=
|
||||
@@ -388,11 +412,13 @@ github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTH
|
||||
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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-20260129164528-943061e2742c h1:ysO2h2Odnl1AJM1I2Lm/fa6JvO0pECMSt2CwBaa+ITo=
|
||||
github.com/golang/geo v0.0.0-20260129164528-943061e2742c/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6 h1:maAhA7cpighmEi96eTgDdmlhwnHYEgwhR6t+T7k3Png=
|
||||
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6/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=
|
||||
@@ -426,22 +452,24 @@ github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
|
||||
github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw=
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A=
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
|
||||
github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U=
|
||||
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
|
||||
github.com/golangci/gofmt v0.0.0-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.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA=
|
||||
github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI=
|
||||
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.12.2 h1:7+d1uY0bq1MU2UV3R5pW5Q7QWdcoq4naMRXM+gsJKrs=
|
||||
github.com/golangci/golangci-lint/v2 v2.12.2/go.mod h1:opqHHuIcTG2R+4akzWMd4o1BnD9/1LcjICWOujr91U8=
|
||||
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=
|
||||
github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba h1:lqtcnSMDuuJdu/LrKWi5RJzpSNLOJXYe/nzQutTI5kg=
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sCBNcpRmhJCtbFGz49+IM3ETTFf7QdJ30AeYCd43NKk=
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
|
||||
@@ -461,6 +489,10 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
@@ -471,8 +503,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=
|
||||
@@ -496,8 +528,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-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=
|
||||
@@ -505,8 +537,8 @@ 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.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -518,14 +550,24 @@ 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=
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4=
|
||||
github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako=
|
||||
github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs=
|
||||
github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c=
|
||||
github.com/jgautheron/goconst v1.10.0 h1:Ptt+OoE4NaEWKhLrWrrN3IpZdGLiqaf7WLnEX/iv4Jw=
|
||||
github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc=
|
||||
github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
|
||||
github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
@@ -543,12 +585,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.7.0 h1:RKsl6Ascal+3kyAqR5Qcbp83LceQMLc1VZbPfHWoNzs=
|
||||
github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c=
|
||||
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=
|
||||
@@ -558,8 +600,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
|
||||
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
|
||||
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
|
||||
github.com/kovidgoyal/imaging v1.8.19 h1:zWJdQqF2tfSKjvoB7XpLRhVGbYsze++M0iaqZ4ZkhNk=
|
||||
github.com/kovidgoyal/imaging v1.8.19/go.mod h1:I0q8RdoEuyc4G8GFOF9CaluTUHQSf68d6TmsqpvfRI8=
|
||||
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=
|
||||
@@ -592,8 +634,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
|
||||
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE=
|
||||
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
|
||||
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
|
||||
@@ -604,8 +646,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
|
||||
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww=
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM=
|
||||
github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8=
|
||||
github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA=
|
||||
github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0=
|
||||
github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g=
|
||||
github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8=
|
||||
github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ=
|
||||
github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs=
|
||||
@@ -618,16 +660,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-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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/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.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=
|
||||
@@ -643,10 +685,10 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
|
||||
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
|
||||
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
|
||||
@@ -659,8 +701,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=
|
||||
@@ -671,10 +713,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.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
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.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps=
|
||||
github.com/onsi/ginkgo/v2 v2.28.2/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=
|
||||
@@ -686,10 +728,12 @@ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2D
|
||||
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -698,8 +742,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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=
|
||||
@@ -736,7 +778,6 @@ github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74
|
||||
github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
@@ -749,8 +790,10 @@ 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/ryancurrah/gomodguard/v2 v2.1.3 h1:E7sz3PJwE9Ba1reVxSpF6XLCPJZ74Kfw/LabTNM4GIA=
|
||||
github.com/ryancurrah/gomodguard/v2 v2.1.3/go.mod h1:CQicdLGatWMxLX53JzoBjYlsNZhHbmLv2AVa0s2aivU=
|
||||
github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg=
|
||||
github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
|
||||
github.com/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=
|
||||
@@ -759,24 +802,22 @@ 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.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.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4=
|
||||
github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE=
|
||||
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=
|
||||
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
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/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
|
||||
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
|
||||
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.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY=
|
||||
github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
@@ -802,7 +843,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=
|
||||
@@ -823,14 +863,16 @@ github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA
|
||||
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
|
||||
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
|
||||
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
|
||||
github.com/tetafro/godot v1.5.4 h1:u1ww+gqpRLiIA16yF2PV1CV1n/X3zhyezbNXC3E14Sg=
|
||||
github.com/tetafro/godot v1.5.4/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
|
||||
github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
|
||||
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk=
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
|
||||
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0=
|
||||
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
|
||||
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
|
||||
github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
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=
|
||||
@@ -841,18 +883,22 @@ 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/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
|
||||
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
|
||||
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.2 h1:06Vq5RKVYThBsj0Bnw4oasMjD1r+7CE/bcKOA8dVSvg=
|
||||
github.com/uudashr/iface v1.4.2/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
||||
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
|
||||
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
|
||||
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=
|
||||
@@ -863,7 +909,6 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
@@ -876,10 +921,10 @@ go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
|
||||
go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
|
||||
go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
|
||||
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
|
||||
go-simpler.org/sloglint v0.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-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4=
|
||||
go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I=
|
||||
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
|
||||
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
|
||||
go.augendre.info/fatcontext v0.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=
|
||||
@@ -890,10 +935,10 @@ 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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
@@ -907,10 +952,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
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=
|
||||
@@ -921,16 +964,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-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
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.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
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=
|
||||
@@ -950,14 +993,11 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
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=
|
||||
@@ -990,26 +1030,22 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
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=
|
||||
@@ -1023,10 +1059,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.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=
|
||||
@@ -1062,32 +1096,22 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.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=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1096,15 +1120,13 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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=
|
||||
@@ -1149,14 +1171,11 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
|
||||
golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
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=
|
||||
@@ -1242,8 +1261,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -1262,7 +1281,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=
|
||||
@@ -1272,32 +1290,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.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
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.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
|
||||
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
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.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
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)
|
||||
}
|
||||
}
|
||||
+136
-109
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
"github.com/golang-jwt/jwt/v4/request"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
@@ -87,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 {
|
||||
@@ -121,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.).
|
||||
@@ -132,20 +221,22 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
// @Param X-Password header string true "URL-encoded password"
|
||||
// @Param X-Secret header string false "TOTP code (if 2FA is enabled)"
|
||||
// @Success 200 {string} string "JWT token for authentication"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - authentication failed"
|
||||
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
|
||||
// @Failure 429 {object} map[string]string "Too many requests - rate limited or temporarily locked out after failed attempts"
|
||||
// @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
|
||||
if d.user.HasPasskeyMFA() && d.user.TOTPSecret == "" {
|
||||
return http.StatusForbidden, errors.ErrPasskeyMFARequired
|
||||
}
|
||||
return printToken(w, r, d.user)
|
||||
}
|
||||
|
||||
// logoutHandler handles user logout
|
||||
@@ -157,7 +248,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
|
||||
@@ -170,7 +263,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
|
||||
}
|
||||
@@ -187,6 +280,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")
|
||||
@@ -219,7 +322,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/signup [post]
|
||||
func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !settings.Config.Auth.Methods.PasswordAuth.Signup {
|
||||
if !config.Auth.Methods.PasswordAuth.Signup {
|
||||
return http.StatusMethodNotAllowed, fmt.Errorf("signup is disabled")
|
||||
}
|
||||
|
||||
@@ -239,7 +342,7 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions))
|
||||
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(config.UserDefaults.Account.Permissions))
|
||||
if err != nil {
|
||||
logger.Debug(err.Error())
|
||||
// Return the actual error message instead of a generic one
|
||||
@@ -257,7 +360,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)
|
||||
@@ -265,7 +368,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
|
||||
@@ -277,90 +380,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
|
||||
@@ -418,10 +447,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)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Built-in auth rate limits (per process). Toggle all off with http.disableRateLimit.
|
||||
//
|
||||
// Credential tier (login, OTP verify): dual token buckets (per IP + per username) plus
|
||||
// failed-login lockout (per IP+username). Tune burst ≤ maxAttempts so rapid floods hit
|
||||
// HTTP 429 from the bucket before lockout; lockout covers paced attacks that stay under RPM.
|
||||
const (
|
||||
authCredentialRPM = 10 // sustained ~1 attempt / 6s per IP and per username
|
||||
authCredentialBurst = 8 // rapid burst; 9th immediate attempt gets 429
|
||||
authModerateRPM = 30 // signup, logout, OTP generate
|
||||
authModerateBurst = 10
|
||||
authOIDCRPM = 60 // OIDC redirects (browser-driven, higher ceiling)
|
||||
authOIDCBurst = 20
|
||||
authAuthenticatedRPM = 180 // session/token management for logged-in users
|
||||
authAuthenticatedBurst = 60
|
||||
authFailedLoginMaxAttempts = 8 // lockout after N consecutive 401s (same IP + username)
|
||||
authFailedLoginLockoutMins = 15
|
||||
// Evict idle per-key token buckets so unique IPs/usernames cannot grow without bound.
|
||||
authLimiterEntryTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// In-memory failed-attempt counters and lockout flags (per server process).
|
||||
var (
|
||||
authFailCounts = cache.NewCache[int](time.Minute, 10*time.Minute)
|
||||
authLockouts = cache.NewCache[bool](time.Minute, 10*time.Minute)
|
||||
)
|
||||
|
||||
// Per-route-class token buckets (keys: IP or username). Expired entries are dropped by go-cache.
|
||||
var (
|
||||
authRateLimitCredentialByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
authRateLimitCredentialByUsername = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
authRateLimitModerateByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
authRateLimitOIDCByIP = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
authRateLimitAuthenticatedByUser = cache.NewCache[*rate.Limiter](authLimiterEntryTTL)
|
||||
)
|
||||
|
||||
const authRateKeySep = "\x1e"
|
||||
|
||||
// AuthRateLimitKind selects which /api/auth rate limit tier applies (see withRateLimit and withRateLimitChain).
|
||||
type AuthRateLimitKind int
|
||||
|
||||
const (
|
||||
// AuthRateLimitCredential: strict per-IP and per-username limits; no failed-login lockout.
|
||||
AuthRateLimitCredential AuthRateLimitKind = iota
|
||||
// AuthRateLimitCredentialLockout: same limits as Credential, plus lockout after repeated 401s.
|
||||
AuthRateLimitCredentialLockout
|
||||
AuthRateLimitModerate
|
||||
AuthRateLimitOIDC
|
||||
// AuthRateLimitAuthenticated: per logged-in username (compose: withUser(withRateLimitChain(AuthRateLimitAuthenticated, h))).
|
||||
AuthRateLimitAuthenticated
|
||||
)
|
||||
|
||||
// withRateLimitChain applies a rate limit tier then fn for nesting inside withUser / withOrWithoutUser / withoutUser.
|
||||
func withRateLimitChain(kind AuthRateLimitKind, fn handleFunc) handleFunc {
|
||||
switch kind {
|
||||
case AuthRateLimitCredential:
|
||||
return withAuthRateLimitCredential(false, fn)
|
||||
case AuthRateLimitCredentialLockout:
|
||||
return withAuthRateLimitCredential(true, fn)
|
||||
case AuthRateLimitModerate:
|
||||
return withAuthRateLimitModerate(fn)
|
||||
case AuthRateLimitOIDC:
|
||||
return withAuthRateLimitOIDC(fn)
|
||||
case AuthRateLimitAuthenticated:
|
||||
return withAuthRateLimitAuthenticated(fn)
|
||||
default:
|
||||
return fn
|
||||
}
|
||||
}
|
||||
|
||||
// withRateLimit registers a rate-limited route (same shape as withTimeout: option first, handler second).
|
||||
func withRateLimit(kind AuthRateLimitKind, fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(withRateLimitChain(kind, fn))
|
||||
}
|
||||
|
||||
func authRateLimitActive() bool {
|
||||
if config.Http.DisableRateLimit {
|
||||
return false
|
||||
}
|
||||
if config.Auth.Methods.NoAuth {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func tokenBucketLimiter(c *cache.KeyCache[*rate.Limiter], key string, requestsPerMinute, burst int) *rate.Limiter {
|
||||
if requestsPerMinute < 1 {
|
||||
requestsPerMinute = 1
|
||||
}
|
||||
if burst < 1 {
|
||||
burst = 1
|
||||
}
|
||||
if lim, ok := c.Get(key); ok && lim != nil {
|
||||
c.SetWithExp(key, lim, authLimiterEntryTTL)
|
||||
return lim
|
||||
}
|
||||
lim := rate.NewLimiter(rate.Limit(float64(requestsPerMinute))/60.0, burst)
|
||||
c.SetWithExp(key, lim, authLimiterEntryTTL)
|
||||
return lim
|
||||
}
|
||||
|
||||
func retryAfterSeconds(lim *rate.Limiter) int {
|
||||
res := lim.Reserve()
|
||||
delay := res.Delay()
|
||||
res.Cancel()
|
||||
secs := int(math.Ceil(delay.Seconds()))
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
return secs
|
||||
}
|
||||
|
||||
func allowCredential(r *http.Request, loginUsername string) (retryAfter int, ok bool) {
|
||||
ip := getRemoteIP(r)
|
||||
limIP := tokenBucketLimiter(authRateLimitCredentialByIP, ip, authCredentialRPM, authCredentialBurst)
|
||||
if !limIP.Allow() {
|
||||
return retryAfterSeconds(limIP), false
|
||||
}
|
||||
if loginUsername != "" {
|
||||
limUser := tokenBucketLimiter(authRateLimitCredentialByUsername, loginUsername, authCredentialRPM, authCredentialBurst)
|
||||
if !limUser.Allow() {
|
||||
return retryAfterSeconds(limUser), false
|
||||
}
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func allowModerate(r *http.Request) (retryAfter int, ok bool) {
|
||||
ip := getRemoteIP(r)
|
||||
lim := tokenBucketLimiter(authRateLimitModerateByIP, ip, authModerateRPM, authModerateBurst)
|
||||
if !lim.Allow() {
|
||||
return retryAfterSeconds(lim), false
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func allowOIDC(r *http.Request) (retryAfter int, ok bool) {
|
||||
ip := getRemoteIP(r)
|
||||
lim := tokenBucketLimiter(authRateLimitOIDCByIP, ip, authOIDCRPM, authOIDCBurst)
|
||||
if !lim.Allow() {
|
||||
return retryAfterSeconds(lim), false
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func allowAuthenticated(username string) (retryAfter int, ok bool) {
|
||||
lim := tokenBucketLimiter(authRateLimitAuthenticatedByUser, username, authAuthenticatedRPM, authAuthenticatedBurst)
|
||||
if !lim.Allow() {
|
||||
return retryAfterSeconds(lim), false
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func authFailKey(ip, username string) string {
|
||||
return "fail" + authRateKeySep + ip + authRateKeySep + username
|
||||
}
|
||||
|
||||
func authLockKey(ip, username string) string {
|
||||
return "lock" + authRateKeySep + ip + authRateKeySep + username
|
||||
}
|
||||
|
||||
func authLockoutRetryAfterSecs() int {
|
||||
return authFailedLoginLockoutMins * 60
|
||||
}
|
||||
|
||||
func isAuthLockout(ip, username string) bool {
|
||||
locked, ok := authLockouts.Get(authLockKey(ip, username))
|
||||
return ok && locked
|
||||
}
|
||||
|
||||
func clearAuthLockout(ip, username string) {
|
||||
authLockouts.Delete(authLockKey(ip, username))
|
||||
authFailCounts.Delete(authFailKey(ip, username))
|
||||
}
|
||||
|
||||
func recordAuthFailure(ip, username string) {
|
||||
if username == "" {
|
||||
return
|
||||
}
|
||||
window := time.Duration(authFailedLoginLockoutMins) * time.Minute
|
||||
fk := authFailKey(ip, username)
|
||||
n := 0
|
||||
if v, ok := authFailCounts.Get(fk); ok {
|
||||
n = v
|
||||
}
|
||||
n++
|
||||
authFailCounts.SetWithExp(fk, n, window)
|
||||
if n >= authFailedLoginMaxAttempts {
|
||||
authLockouts.SetWithExp(authLockKey(ip, username), true, window)
|
||||
}
|
||||
}
|
||||
|
||||
// withRateLimitInternal applies token-bucket allow checks: when inactive, calls fn; when denied, 429 + Retry-After.
|
||||
func withRateLimitInternal(fn handleFunc, allow func(*http.Request, *requestContext) (retryAfter int, ok bool)) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !authRateLimitActive() {
|
||||
return fn(w, r, d)
|
||||
}
|
||||
if after, ok := allow(r, d); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(after))
|
||||
return http.StatusTooManyRequests, fmt.Errorf("too many requests")
|
||||
}
|
||||
return fn(w, r, d)
|
||||
}
|
||||
}
|
||||
|
||||
// withAuthRateLimitCredential enforces the credential tier (and optional per-username limits).
|
||||
// When trackFailures is true, 401 responses increment failed-attempt state for lockout; success clears it.
|
||||
func withAuthRateLimitCredential(trackFailures bool, fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !authRateLimitActive() {
|
||||
return fn(w, r, d)
|
||||
}
|
||||
username := r.URL.Query().Get("username")
|
||||
if isAuthLockout(getRemoteIP(r), username) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(authLockoutRetryAfterSecs()))
|
||||
return http.StatusTooManyRequests, fmt.Errorf("too many failed authentication attempts")
|
||||
}
|
||||
if after, ok := allowCredential(r, username); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(after))
|
||||
return http.StatusTooManyRequests, fmt.Errorf("too many requests")
|
||||
}
|
||||
status, err := fn(w, r, d)
|
||||
if !trackFailures {
|
||||
return status, err
|
||||
}
|
||||
ip := getRemoteIP(r)
|
||||
if status == http.StatusUnauthorized && username != "" {
|
||||
recordAuthFailure(ip, username)
|
||||
}
|
||||
if err == nil && (status == 0 || status == http.StatusOK) {
|
||||
clearAuthLockout(ip, username)
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
}
|
||||
|
||||
func withAuthRateLimitModerate(fn handleFunc) handleFunc {
|
||||
return withRateLimitInternal(fn, func(r *http.Request, _ *requestContext) (int, bool) {
|
||||
return allowModerate(r)
|
||||
})
|
||||
}
|
||||
|
||||
func withAuthRateLimitOIDC(fn handleFunc) handleFunc {
|
||||
return withRateLimitInternal(fn, func(r *http.Request, _ *requestContext) (int, bool) {
|
||||
return allowOIDC(r)
|
||||
})
|
||||
}
|
||||
|
||||
func withAuthRateLimitAuthenticated(fn handleFunc) handleFunc {
|
||||
return withRateLimitInternal(fn, func(_ *http.Request, d *requestContext) (int, bool) {
|
||||
if d.user == nil || d.user.Username == "" || d.user.Username == "anonymous" {
|
||||
return 0, true
|
||||
}
|
||||
return allowAuthenticated(d.user.Username)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// throttledReadSeeker is a wrapper around an io.ReadSeeker that throttles the reading speed.
|
||||
// Used for single-file downloads and for share archive downloads (see serveArchiveWithServeContent);
|
||||
// archives are built to a temp file first, then bytes to the client are limited on read.
|
||||
type throttledReadSeeker struct {
|
||||
rs io.ReadSeeker
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// newThrottledReadSeeker creates a new throttledReadSeeker.
|
||||
func newThrottledReadSeeker(rs io.ReadSeeker, limit rate.Limit, burst int, ctx context.Context) *throttledReadSeeker {
|
||||
return &throttledReadSeeker{
|
||||
rs: rs,
|
||||
limiter: rate.NewLimiter(limit, burst),
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *throttledReadSeeker) Read(p []byte) (n int, err error) {
|
||||
n, err = r.rs.Read(p)
|
||||
if n > 0 {
|
||||
if waitErr := r.limiter.WaitN(r.ctx, n); waitErr != nil {
|
||||
// The original error (like io.EOF) is potentially more important
|
||||
// than the context error.
|
||||
if err == nil {
|
||||
err = waitErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
return r.rs.Seek(offset, whence)
|
||||
}
|
||||
|
||||
// throttledWriter wraps an io.Writer and rate-limits outbound bytes (streaming archives to the client).
|
||||
type throttledWriter struct {
|
||||
w io.Writer
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func newThrottledWriter(w io.Writer, limit rate.Limit, burst int, ctx context.Context) *throttledWriter {
|
||||
return &throttledWriter{
|
||||
w: w,
|
||||
limiter: rate.NewLimiter(limit, burst),
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (tw *throttledWriter) Write(p []byte) (n int, err error) {
|
||||
n, err = tw.w.Write(p)
|
||||
if n > 0 {
|
||||
if waitErr := tw.limiter.WaitN(tw.ctx, n); waitErr != nil {
|
||||
if err == nil {
|
||||
err = waitErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// toASCIIFilename converts a filename to ASCII-safe format by replacing non-ASCII characters with underscores
|
||||
func toASCIIFilename(fileName string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range fileName {
|
||||
if r > 127 {
|
||||
// Replace non-ASCII characters with underscore
|
||||
result.WriteRune('_')
|
||||
} else {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
|
||||
dispositionType := "attachment"
|
||||
if r.URL.Query().Get("inline") == "true" {
|
||||
dispositionType = "inline"
|
||||
// Inline SVG (and similar) can execute embedded scripts when opened as a top-level document; match upstream filebrowser mitigation.
|
||||
w.Header().Set("Content-Security-Policy", "script-src 'none'")
|
||||
}
|
||||
|
||||
// standard: ASCII-only safe fallback
|
||||
asciiFileName := toASCIIFilename(fileName)
|
||||
// RFC 5987: UTF-8 encoded
|
||||
encodedFileName := url.PathEscape(fileName)
|
||||
|
||||
// Always set both filename (ASCII) and filename* (UTF-8) for maximum compatibility (RFC 6266)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q; filename*=utf-8''%s", dispositionType, asciiFileName, encodedFileName))
|
||||
}
|
||||
|
||||
// downloadHandler serves the raw content of a file, multiple files, or directory in various formats.
|
||||
// @Summary Download content of a file, multiple files, or directory
|
||||
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
|
||||
// @Description
|
||||
// @Description **Filename Encoding:**
|
||||
// @Description - The Content-Disposition header will always include both:
|
||||
// @Description 1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
|
||||
// @Description 2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
|
||||
// @Description
|
||||
// @Description **Multiple Files:**
|
||||
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
|
||||
// @Description - This supports filenames containing commas and special characters
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Param source query string true "Source name for the files (required)"
|
||||
// @Param file query []string true "File path (can be repeated for multiple files)"
|
||||
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
|
||||
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
|
||||
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
|
||||
// @Failure 202 {object} map[string]string "Modify permissions required"
|
||||
// @Failure 400 {object} map[string]string "Invalid request path"
|
||||
// @Failure 404 {object} map[string]string "File or directory not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources/download [get]
|
||||
func downloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
source := r.URL.Query().Get("source")
|
||||
fileList := r.URL.Query()["file"]
|
||||
|
||||
// Rule 1: Validate all user-provided file paths to prevent path traversal
|
||||
for i, filePath := range fileList {
|
||||
cleanPath, err := utils.SanitizeUserPath(filePath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
|
||||
}
|
||||
fileList[i] = cleanPath
|
||||
}
|
||||
|
||||
return rawFilesHandler(w, r, d, source, fileList)
|
||||
}
|
||||
|
||||
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
|
||||
if !d.user.Permissions.Download && d.share == nil {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to download")
|
||||
}
|
||||
|
||||
if len(fileList) == 0 && d.share == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("no files specified")
|
||||
}
|
||||
|
||||
firstFilePath := fileList[0]
|
||||
var err error
|
||||
var userscope string
|
||||
fileName := filepath.Base(firstFilePath)
|
||||
|
||||
// Check if this is an OnlyOffice file early for error logging
|
||||
isOnlyOffice := isOnlyOfficeCompatibleFile(fileName) && config.Integrations.OnlyOffice.Url != ""
|
||||
var documentId string
|
||||
var logContext *OnlyOfficeLogContext
|
||||
|
||||
// modify all filepaths for user scope
|
||||
if d.share == nil {
|
||||
userscope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice file
|
||||
if isOnlyOffice {
|
||||
// Try to get document ID for error logging
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx != nil {
|
||||
tempPath := utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
if realPath, _, realErr := idx.GetRealPath(tempPath); realErr == nil {
|
||||
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
|
||||
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
|
||||
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - source not available: %s - %v", firstFilePath, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
for i, filePath := range fileList {
|
||||
fileList[i] = utils.JoinPathAsUnix(userscope, filePath)
|
||||
}
|
||||
}
|
||||
firstFilePath = fileList[0]
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice file
|
||||
if isOnlyOffice {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - source index not available: %s", source))
|
||||
}
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
realPath, isDir, err := idx.GetRealPath(firstFilePath)
|
||||
if err != nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice file
|
||||
if isOnlyOffice {
|
||||
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
|
||||
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
|
||||
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - could not resolve path: %s - %v", firstFilePath, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// ** Single file download with Content-Length **
|
||||
if len(fileList) == 1 && !isDir {
|
||||
// Get document ID and log context for OnlyOffice downloads
|
||||
if isOnlyOffice {
|
||||
documentId, _ = getOnlyOfficeId(realPath)
|
||||
if documentId != "" {
|
||||
logContext = getOnlyOfficeLogContext(documentId)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify access control before opening the file (direct rule check)
|
||||
if d.share == nil && store.Access != nil {
|
||||
if !store.Access.Permitted(idx.Path, firstFilePath, d.user.Username) {
|
||||
logger.Debugf("user %s denied access to path %s", d.user.Username, firstFilePath)
|
||||
// Send OnlyOffice error log if this was an OnlyOffice download
|
||||
if isOnlyOffice && logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - access denied by rule: %s", firstFilePath))
|
||||
}
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", firstFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
fd, err2 := os.Open(realPath)
|
||||
if err2 != nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice download
|
||||
if isOnlyOffice && logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - could not open file: %s - %v", firstFilePath, err2))
|
||||
}
|
||||
return http.StatusInternalServerError, err2
|
||||
}
|
||||
defer fd.Close()
|
||||
|
||||
// Get file size
|
||||
fileInfo, err2 := fd.Stat()
|
||||
if err2 != nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice download
|
||||
if isOnlyOffice && logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - could not get file info: %s - %v", firstFilePath, err2))
|
||||
}
|
||||
return http.StatusInternalServerError, err2
|
||||
}
|
||||
|
||||
// Send success log for OnlyOffice downloads
|
||||
if isOnlyOffice && logContext != nil {
|
||||
logger.Infof("OnlyOffice Server is downloading file: %s (documentId: %s)",
|
||||
firstFilePath, documentId)
|
||||
|
||||
sendOnlyOfficeLogEvent(logContext, "INFO", "download",
|
||||
fmt.Sprintf("OnlyOffice Server downloading file: %s", firstFilePath))
|
||||
}
|
||||
|
||||
// ServeContent sets Content-Length (full resource or range); avoid pre-setting so partial/range responses stay consistent.
|
||||
setContentDisposition(w, r, fileName)
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
// video scrubbing, etc.
|
||||
// Note: http.ServeContent will respect our already-set Content-Disposition header
|
||||
var reader io.ReadSeeker = fd
|
||||
if d.share != nil && d.share.MaxBandwidth > 0 {
|
||||
// convert KB/s to B/s
|
||||
limit := rate.Limit(d.share.MaxBandwidth * 1024)
|
||||
// burst size can be the same as limit
|
||||
burst := d.share.MaxBandwidth * 1024
|
||||
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
|
||||
}
|
||||
http.ServeContent(w, r, fileName, fileInfo.ModTime(), reader)
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
// ** Archive (ZIP/TAR.GZ) handling ** — delegate to archive package
|
||||
return BuildAndStreamArchive(w, r, d, source, fileList)
|
||||
}
|
||||
|
||||
// isOnlyOfficeCompatibleFile checks if a file extension is supported by OnlyOffice
|
||||
func isOnlyOfficeCompatibleFile(fileName string) bool {
|
||||
return iteminfo.IsOnlyOffice(fileName)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func (s *duplicateProcessingStats) shouldStop() (bool, string) {
|
||||
//
|
||||
// @Summary Find Duplicate Files
|
||||
// @Description Finds duplicate files using multi-stage filtering: size → type → fuzzy filename → progressive checksums. Files must match on size, MIME type, and have 50%+ filename similarity before checksum verification. Large fuzzy groups (>10 files) are skipped to avoid false positives. Checksums use 2-pass progressive verification (header → middle) for accuracy while minimizing disk I/O (~16KB read per file).
|
||||
// @Tags Duplicates
|
||||
// @Tags Tools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param source query string true "Source name for the desired source"
|
||||
@@ -142,7 +142,7 @@ func (s *duplicateProcessingStats) shouldStop() (bool, string) {
|
||||
// @Success 200 {object} duplicateResponse "List of duplicate file groups with metadata. Response includes 'incomplete' flag if processing stopped early due to resource limits."
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 503 {object} map[string]string "Service Unavailable (indexing in progress or another search running)"
|
||||
// @Router /api/duplicates [get]
|
||||
// @Router /api/tools/duplicateFinder [get]
|
||||
func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
opts, err := prepDuplicatesOptions(r, d)
|
||||
if err != nil {
|
||||
|
||||
+71
-80
@@ -26,10 +26,9 @@ type fileWatchResponse struct {
|
||||
// fileWatchMetadata contains file information for non-text files
|
||||
type fileWatchMetadata struct {
|
||||
Name string `json:"name"` // File name
|
||||
Size int64 `json:"size"` // File size in bytes
|
||||
Size int64 `json:"size"` // File size in bytes (for directories, total size of all files)
|
||||
Type string `json:"type"` // MIME type
|
||||
Modified time.Time `json:"modified"` // Modification time
|
||||
Path string `json:"path"` // File path
|
||||
}
|
||||
|
||||
// readLastNLines reads the last N lines from a file efficiently
|
||||
@@ -123,7 +122,7 @@ func readLastNLines(filePath string, n int) (string, error) {
|
||||
// @Failure 403 {object} map[string]string "Permission denied"
|
||||
// @Failure 404 {object} map[string]string "File not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/tools/watch [get]
|
||||
// @Router /api/tools/fileWatcher [get]
|
||||
func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Check for latency check request - return immediately with minimal response
|
||||
if r.URL.Query().Get("latencyCheck") != "" {
|
||||
@@ -140,7 +139,7 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
var err error
|
||||
path, err = utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
// Parse lines parameter
|
||||
@@ -190,55 +189,43 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's a file, not a directory
|
||||
// Get file/directory info
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is a directory, not a file")
|
||||
}
|
||||
|
||||
// Check if file is a text file
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
}
|
||||
|
||||
// Get MIME type from the index if available
|
||||
mimeType := "application/octet-stream"
|
||||
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
|
||||
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
|
||||
if exists && reducedInfo.Type != "" {
|
||||
mimeType = reducedInfo.Type
|
||||
}
|
||||
|
||||
response := fileWatchResponse{
|
||||
IsText: isText,
|
||||
response := fileWatchResponse{}
|
||||
// Handle directory - just return metadata, no content
|
||||
response.IsText = false
|
||||
response.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: 0, // Directories don't have a meaningful size
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
}
|
||||
|
||||
if isText {
|
||||
// Read the last N lines for text files
|
||||
content, err := readLastNLines(realPath, lines)
|
||||
if !info.IsDir() {
|
||||
// Handle regular file
|
||||
// Check if file is a text file
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error reading file: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
|
||||
}
|
||||
response.Contents = content
|
||||
// Include metadata for text files too (for file size display)
|
||||
response.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
} else {
|
||||
// For non-text files, return metadata
|
||||
response.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
if isText {
|
||||
response.IsText = true
|
||||
// Read the last N lines for text files only
|
||||
content, err := readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error reading file: %v", err)
|
||||
}
|
||||
response.Contents = content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +252,7 @@ type fileWatchSSEEvent struct {
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 403 {object} map[string]string "Permission denied"
|
||||
// @Failure 404 {object} map[string]string "File not found"
|
||||
// @Router /api/tools/watch/sse [get]
|
||||
// @Router /api/tools/fileWatcher/sse [get]
|
||||
func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Check realtime permissions
|
||||
if !(d.user.Permissions.Realtime) {
|
||||
@@ -289,7 +276,7 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
@@ -346,16 +333,13 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// Get real file path
|
||||
realPath, _, err := idx.GetRealPath(scopePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's a file, not a directory
|
||||
// Get file/directory info
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is a directory, not a file")
|
||||
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
|
||||
}
|
||||
|
||||
// Set up SSE headers
|
||||
@@ -386,10 +370,13 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// Get MIME type (we'll reuse this)
|
||||
mimeType := "application/octet-stream"
|
||||
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
|
||||
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
|
||||
if exists && reducedInfo.Type != "" {
|
||||
mimeType = reducedInfo.Type
|
||||
}
|
||||
|
||||
// Determine if this is a directory
|
||||
isDir := info.IsDir()
|
||||
|
||||
// Start background goroutine to periodically send file updates via events system
|
||||
stopTicker := make(chan struct{})
|
||||
go func() {
|
||||
@@ -397,14 +384,14 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
defer ticker.Stop()
|
||||
|
||||
// Send initial update
|
||||
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
|
||||
sendFileWatchUpdate(username, realPath, path, source, lines, mimeType, isDir)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopTicker:
|
||||
return
|
||||
case <-ticker.C:
|
||||
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
|
||||
sendFileWatchUpdate(username, realPath, path, source, lines, mimeType, isDir)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -443,54 +430,58 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
}
|
||||
|
||||
// sendFileWatchUpdate reads the file and sends an update via the events system
|
||||
func sendFileWatchUpdate(username, realPath, path string, lines int, mimeType string) {
|
||||
// Re-check file (in case it was deleted or changed)
|
||||
// sendFileWatchUpdate reads the file/directory and sends an update via the events system
|
||||
func sendFileWatchUpdate(username, realPath, path, source string, lines int, mimeType string, isDir bool) {
|
||||
// Re-check path (in case it was deleted or changed)
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
// File no longer exists
|
||||
errorMsg, _ := json.Marshal(map[string]interface{}{"status": "error", "error": "file not found"})
|
||||
// Path no longer exists
|
||||
errorMsg, _ := json.Marshal(map[string]interface{}{"status": "error", "error": "path not found"})
|
||||
events.SendToUsers("fileWatch", string(errorMsg), []string{username})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file is a text file
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
// Error checking file, skip this update
|
||||
return
|
||||
}
|
||||
|
||||
// Build the SSE event
|
||||
sseEvent := fileWatchSSEEvent{
|
||||
IsText: isText,
|
||||
}
|
||||
sseEvent := fileWatchSSEEvent{}
|
||||
|
||||
if isText {
|
||||
// Read the last N lines for text files
|
||||
var content string
|
||||
content, err = readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
// Error reading, skip this update
|
||||
return
|
||||
}
|
||||
sseEvent.Contents = content
|
||||
if isDir {
|
||||
// Handle directory - just return metadata, no content
|
||||
sseEvent.IsText = false
|
||||
sseEvent.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Size: 0, // Directories don't have a meaningful size
|
||||
Type: "directory",
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
} else {
|
||||
// For non-text files, send metadata
|
||||
// Handle regular file
|
||||
// Check if file is a text file
|
||||
var isText bool
|
||||
isText, err = utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
// Error checking file, skip this update
|
||||
return
|
||||
}
|
||||
|
||||
sseEvent.IsText = isText
|
||||
sseEvent.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
|
||||
if isText {
|
||||
// Read the last N lines for text files only
|
||||
var content string
|
||||
content, err = readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
// Error reading, skip this update
|
||||
return
|
||||
}
|
||||
sseEvent.Contents = content
|
||||
}
|
||||
// For non-text files, Contents stays empty - frontend displays metadata
|
||||
}
|
||||
|
||||
// Serialize and send via events system
|
||||
|
||||
Loaded 100 of 387 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user