mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-15 05:31:59 -05:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79552f8adb | ||
|
|
951debcd87 | ||
|
|
cd8519c8aa | ||
|
|
da085bca69 | ||
|
|
64250ffff0 | ||
|
|
0bc8889c00 | ||
|
|
6eaead073a | ||
|
|
826b060bb1 | ||
|
|
21dfc6169e | ||
|
|
4708faec5f | ||
|
|
fa5abc8c67 | ||
|
|
687dc01293 | ||
|
|
bfde65a41e | ||
|
|
1802e12811 | ||
|
|
0f215333ee | ||
|
|
6d81d1973c | ||
|
|
869b640a99 | ||
|
|
3b8d179774 | ||
|
|
569314cdf4 | ||
|
|
668efec4d7 | ||
|
|
a5c81c8e2e | ||
|
|
88aa337696 | ||
|
|
9b48ce414e | ||
|
|
0723edf660 | ||
|
|
d2165ec0c3 | ||
|
|
78c554719f | ||
|
|
aa8f9e2923 |
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 @@
|
||||
*.db
|
||||
*.db.old
|
||||
*.sqlite
|
||||
*.bak
|
||||
*.log
|
||||
*.mjs
|
||||
@@ -21,6 +23,7 @@ frontend/tsconfig.tsbuildinfo
|
||||
/frontend/generated
|
||||
/frontend/tests/playwright-files/demo*
|
||||
/frontend/loginAuth.json
|
||||
/frontend/sharePrepStorage.json
|
||||
/backend/vendor
|
||||
/backend/*.cov
|
||||
/backend/*test_config.yaml
|
||||
|
||||
+210
@@ -2,11 +2,221 @@
|
||||
|
||||
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.5.2
|
||||
|
||||
**Notes**:
|
||||
- [docker] upgraded ffmpeg version 8.1.1 to 8.1.2
|
||||
|
||||
**BugFixes**:
|
||||
- Fixed OnlyOffice reopening an older editor state after a document is modified (#2633) (#2578).
|
||||
- Cannot "Reset and generate new two-factor code" to reset the TOTP for a user (#2399) (#2641).
|
||||
|
||||
## v1.5.1
|
||||
|
||||
**BugFixes**:
|
||||
- fix http config section being dropped so trustedHeaders and disableRateLimit apply (#2602) (#2560)
|
||||
- fix upload shares with password (#2589) (#2573)
|
||||
- fix token when returning from preview on shares with pass (#2588) (#2465)
|
||||
- fix share undefined url after editing (#2567) (#2523)
|
||||
|
||||
## v1.5.0
|
||||
|
||||
**New Features**:
|
||||
- Added basic html viewer with relative reference support (#2522)
|
||||
- Shares can have pinned files, requires a user to have edit access to share (#2522)
|
||||
- Enhanced search
|
||||
- now uses "lazy" match by default. (#2509)
|
||||
- added missing `case sensitive` option in the UI
|
||||
- Progressive Web App (PWA) improvements
|
||||
- restored install prompt with sidebar install button (#2086)
|
||||
- camera and video capture buttons on upload (mobile-friendly `capture` inputs)
|
||||
- send files to other apps via the Web Share API (`Send to app` in the context menu)
|
||||
- App notifications for file operations (#2478)
|
||||
- optional browser notifications when uploads, chunked downloads, move/copy, or failures finish while the tab is in the background
|
||||
- single on/off toggle in Notifications settings, stored in browser local storage
|
||||
|
||||
**Notes**:
|
||||
- [docker] upgraded ffmpeg version 8.1 to 8.1.1
|
||||
|
||||
**BugFixes**:
|
||||
- fixed PWA manifest `scope` and `id` so install works when the app is served under a base URL
|
||||
- installing a public share as a PWA now opens the share URL instead of the site root (#2302)
|
||||
|
||||
## v1.4.4
|
||||
|
||||
**BugFixes**:
|
||||
- Pinning/Unpinning file resets users' scope permission (#2532)
|
||||
|
||||
## v1.4.3
|
||||
|
||||
**Security**:
|
||||
- [High] Path traversal in subtitle handler allows any authenticated user to read arbitrary files (GHSA-vvp7-h4fj-m28w)
|
||||
|
||||
**New Features**:
|
||||
- pinned files/folders (#2396) (#2510)
|
||||
- Markdown Image relative reference support (#2355)
|
||||
- Add an option to create a new folder for unarchiving (#2338)
|
||||
|
||||
**Notes**:
|
||||
- added fallback to show text in notification if copy fails (#2517)
|
||||
- added `alt`+`arrow up` shortcut as alias of `backspace` to go into parent directory (#2501) (#2521)
|
||||
- added `alt`+`arrow down` shortcut as alias of `enter` to open files in Listing View (#2501) (#2521)
|
||||
- updated help menu with better translations
|
||||
- migrate i18n to v11 (#2472) (#2504)
|
||||
- migrate eslint (#2459)
|
||||
- improved preview cancellation to improve performance when navigating UI.
|
||||
|
||||
**BugFixes**:
|
||||
- fix lyrics regex parsing (#2505)
|
||||
- fix folder previews issue (#2487) (#2492)
|
||||
- fix accidental exit on images while using gestures (#2508)
|
||||
- navigating using the up/down arrow keys in Listing View was "jumping randomly" in some view modes (#2521)
|
||||
- socket field in config.yaml is ignored (#2497)
|
||||
- fix keep opened file selected after closing its preview @anpryl (#2515)
|
||||
|
||||
|
||||
## v1.4.2
|
||||
|
||||
**Security**:
|
||||
- [Moderate] Add Rate Limiting on Authentication Endpoint Enables Brute Force Attacks (GHSA-r4v7-6wcg-ghj5)
|
||||
- [Critical] Path traversal in public share PATCH allows file ops outside shared directory -- thanks @Revanth011 and @fg0x0 (GHSA-qqqm-5547-774x)
|
||||
|
||||
**New Features**:
|
||||
- read-only source configuration via `source.config.readOnly: true` (#2438)
|
||||
|
||||
**Notes**:
|
||||
- auth rate limiting can be disabled via `auth.disableRateLimit`
|
||||
- updated share hash middleware (#2443)
|
||||
- updated source info popup to include private and readOnly properties
|
||||
|
||||
**BugFixes**:
|
||||
- Logout from share page now redirects to the share instead of `/Login` again. (#2245)
|
||||
- `This location cannot be reached` error when navigating with FileTree in shares. (#2245)
|
||||
- Fix FileTree rename and move actions in previews. (#2245)
|
||||
- Delete prompt not showing date and thumbnails in some previews. (#2245)
|
||||
- fix path slash issue on windows (#2451) (#2433) (#2419)
|
||||
- Always force url rewrite for onlyoffice internal URL. Fixes Error saving with OnlyOffice (#2450)
|
||||
- Overriding a Deny with an Allow not working (#2405)
|
||||
|
||||
## v1.4.1
|
||||
|
||||
**Security**:
|
||||
- Fix critical: unauthenticated user can view source info
|
||||
|
||||
## v1.4.0
|
||||
|
||||
**New Features**:
|
||||
- Add Passkey (WebAuthn) support for passwordless authentication (#2287) (#2379) -- thanks @juansoler
|
||||
- Lyrics support for audio files (#2360):
|
||||
- Updated UI for audio previews!
|
||||
- The lyrics and the player gets syncronized in UI with the audio player.
|
||||
- Supports embedded lyrics (ID3v2 USLT, flac, ogg, clyr) and sidecar `.lrc` files with the same name as the audio file.
|
||||
- The `Q` shortcut in audio files will toggle the new panel visibility.
|
||||
- `E` shortcut to change between tabs of the panel if it's open.
|
||||
- Api log filtering support via `apiFilter` option for logging.
|
||||
- regex filter that excludes matching full api paths from being logged. (eg. `/user\?id\=self`) Defaults to `^/health|^/favicon.ico|^/static|^/public/static`
|
||||
- Add config option to disable /health endpoint logging (#2291)
|
||||
- More shortcuts (#2300)
|
||||
- `CTRL+F1` switch to normal view.
|
||||
- `CTRL+F2` switch to gallery view.
|
||||
- `CTRL+F3` switch to list view.
|
||||
- `Double click` on empty space to select all items.
|
||||
- Option to hide certain files in UI by extension (#2403) (#2277)
|
||||
- Additional search features
|
||||
- wildcard search similar to regex, but limited to sqlite GLOB syntax (#769)
|
||||
- advanced search tool that shows all results in a listing thats sortable and actionable like a normal listing (#1051)
|
||||
- advanced search supports multiple custom scopes per search
|
||||
- multiple search terms with AND or OR logic support
|
||||
|
||||
**Notes**:
|
||||
- Enhanced indexing scheduler which doesn't wake the disk as often.
|
||||
- New API route `media/lyrics` used to fetch and parse lyrics (embedded or from `.lrc` sidecar) (#2360)
|
||||
- Swiping down gesture in fullscreen videos exit fullscreen instead of close preview.
|
||||
- Improved styles for path selection and tables
|
||||
- Improved style of drag and drop into listing view (#2407)
|
||||
- Edit Sidebar links has new "show tools in sidebar" toggle and all users have this enabled by default. can be disabled via for new users `userDefaults.sidebar.showTools: false`
|
||||
- Update user defaults ordering (#1140)
|
||||
- Save view modes and sizes into local storage instead of db (#2301)
|
||||
|
||||
**BugFixes**:
|
||||
- Blue overlay when using gestures in video files on mobile (#2360)
|
||||
- Playback queue wasn't updating when changing of folder (#2360)
|
||||
- Added missing `UserDefaults.listing.showCopyPath` option to config file (#2364)
|
||||
- Delete prompt thumbnail didn't worked when using it with the `delete` shortcut. (#2407)
|
||||
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
|
||||
- Drag and drop style state was stuck until page reload (#2407)
|
||||
- Navigate close settings shows "something went wrong" (#2047)
|
||||
|
||||
## v1.3.10
|
||||
|
||||
**Notes**:
|
||||
- When indexing is disabled for a source, the usage will always reported as partition size.
|
||||
- removed deprecated `source.config.disableIndexing`, see [rules](https://filebrowserquantum.com/en/docs/advanced/source-configuration/conditional-rules/#disable-indexing)
|
||||
|
||||
**BugFixes**:
|
||||
- Disable index option not working (#2385)
|
||||
|
||||
## v1.3.9
|
||||
|
||||
**Security**:
|
||||
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
|
||||
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
|
||||
|
||||
**BugFixes**:
|
||||
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
|
||||
|
||||
## v1.3.8
|
||||
|
||||
**BugFixes**:
|
||||
- Quick download icon style after icon change.
|
||||
- Missing error popup for resource creatoin actions (upload/create)
|
||||
- EnforcedOtp login failure until restart (#2330)
|
||||
- Thumbnails for Folders only display sporadically (#2353)
|
||||
- Unwanted user scope change for users with non-default scopes (#2347)
|
||||
- Fix sidebar source info totals (#2321) (#2322) (#982)
|
||||
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
|
||||
|
||||
## v1.3.7
|
||||
|
||||
**Notes**:
|
||||
- creating/deleting password-based user requires reauthentication (#2112)
|
||||
|
||||
**BugFixes**:
|
||||
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
|
||||
- No Loginfields shown if password authentification is set to false (#2331)
|
||||
|
||||
## v1.3.6
|
||||
|
||||
**BugFixes**:
|
||||
- file watcher not working for files and not respecting line count (#2314)
|
||||
- "source" type link shows up multiple times when adding sidebar links if user has multiple sources.
|
||||
- JWT ES256 results in error (#2188)
|
||||
- single file shares not loading editor text content (#2122)
|
||||
|
||||
## v1.3.5
|
||||
|
||||
**Notes**:
|
||||
- any password change or admin user property change for a password user requires reauthentication (#2112)
|
||||
|
||||
**BugFixes**:
|
||||
- PWA icon fixes (#2292)
|
||||
- deny-rule'd folders visible in directory listings (regression from v1.2.4-stable) (#2295)
|
||||
- OTP and password requirement fixes (#2112) (#2263)
|
||||
- CLI not picking up config properly
|
||||
- Storage usage numbers on first load adjusted.
|
||||
- Some items in userDefaults are not setting defaults for new users properly #2278
|
||||
- Fix copy to clipboard and simplify code (#2281) (#2274)
|
||||
- Fix search shortcut `/` and pdf previews (#2307)
|
||||
- Special permissions bit not set when declared in createDirectoryPermission (#2283)
|
||||
- Execute permission altered after editing sh script with FB Quantum (#2309)
|
||||
- Source is red but working (#2289)
|
||||
|
||||
## v1.3.4
|
||||
|
||||
**New Features**:
|
||||
- Epub placementg url anchors to bookmark a specific location on the doc.
|
||||
- Add Requirement for Current Password When Changing Account Password (#2112)
|
||||
- Copy file path to clipborad through right click (#2204)
|
||||
|
||||
**Notes**:
|
||||
- user scope editing has path picker and filesystem validation
|
||||
|
||||
+7
-64
@@ -5,17 +5,22 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.25+** (see `backend/go.mod`)
|
||||
- **Node.js 20.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
|
||||
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
|
||||
- **Docker** (optional, for containerized development)
|
||||
- **Git** especially improtant on windows -- needed for bash support.
|
||||
|
||||
### Optional Tools
|
||||
|
||||
- **ffmpeg**: For media features (subtitles, thumbnails, heic)
|
||||
- **ffmpeg/ffprobe**: For media features (subtitles, thumbnails, heic)
|
||||
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
|
||||
- macOS: `brew install ffmpeg`
|
||||
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
|
||||
|
||||
- **exiftool**: For photo metadata processing (RAW/HEIC previews, EXIF orientation)
|
||||
- Ubuntu/Debian: `sudo apt-get install libimage-exiftool-perl`
|
||||
- macOS: `brew install exiftool`
|
||||
- Windows: Download from [exiftool.org](https://exiftool.org/) (install `exiftool.exe` and add it to your PATH, or set `integrations.media.exiftoolPath` in config)
|
||||
|
||||
- **mupdf-tools**: For PDF preview generation
|
||||
- Ubuntu/Debian: `sudo apt-get install mupdf-tools`
|
||||
- macOS: `brew install mupdf-tools`
|
||||
@@ -90,18 +95,6 @@ make test-playwright # E2E tests in Docker
|
||||
|
||||
E2E tests run with three authentication modes: standard auth, no auth, and proxy auth.
|
||||
|
||||
### Coverage & Performance
|
||||
```bash
|
||||
cd backend
|
||||
./run_check_coverage.sh # Coverage report with HTML output
|
||||
./run_benchmark.sh # Benchmarks
|
||||
```
|
||||
|
||||
**Code Coverage:**
|
||||
- View report: Open `backend/coverage.html` after running coverage script
|
||||
- CI enforces coverage for critical packages
|
||||
- Use `go test -cover` for quick package coverage
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (Go)
|
||||
@@ -156,53 +149,3 @@ type(scope): description
|
||||
|
||||
Types: feat, fix, docs, refactor, test, chore
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Check the [Project Roadmap](https://github.com/users/gtsteffaniak/projects/4/views/2) to see issues sorted by priority. This helps you understand what features are planned and where you can contribute most effectively.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Build failures:**
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && rm -rf node_modules && npm install
|
||||
|
||||
# Backend
|
||||
cd backend && go mod tidy && go clean -modcache
|
||||
```
|
||||
|
||||
**Authentication Issues:**
|
||||
|
||||
Always first enable debug logging in your config file if you have issues.
|
||||
|
||||
1. **OIDC Login Fails:**
|
||||
- Check redirect URLs match your config
|
||||
- Verify OIDC provider settings
|
||||
- Enable debug logs to see auth flow
|
||||
- Common issue: mismatched callback URLs
|
||||
|
||||
2. **Proxy Auth Not Working:**
|
||||
- Verify header names match config
|
||||
- Check nginx/reverse proxy passes headers
|
||||
- Test with: `curl -H "X-Auth: username" localhost:8080`
|
||||
- Enable `--log-level debug` to see headers
|
||||
|
||||
3. **TOTP Issues:**
|
||||
- Ensure server time is synchronized
|
||||
- Check QR code generation in logs
|
||||
- Test with authenticator app time settings
|
||||
- Database might have stale TOTP secrets
|
||||
|
||||
4. **Session/Cookie Problems:**
|
||||
- Clear browser cookies and localStorage
|
||||
- Verify cookie domain settings
|
||||
- Try incognito/private browsing mode
|
||||
|
||||
### Getting Help
|
||||
|
||||
- **Wiki**: [Project Wiki](https://github.com/gtsteffaniak/filebrowser/wiki)
|
||||
- **Issues**: [GitHub Issues](https://github.com/gtsteffaniak/filebrowser/issues)
|
||||
- **PR Template**: See `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
@@ -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,8 +17,6 @@
|
||||
|
||||
## Pinned
|
||||
|
||||
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
|
||||
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
## About
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM gtstef/ffmpeg:8.1-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:8.1.2-decode AS ffmpeg
|
||||
FROM golang:alpine AS base
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
|
||||
@@ -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
|
||||
@@ -15,3 +15,4 @@ userDefaults:
|
||||
permissions:
|
||||
download: 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
|
||||
@@ -1,7 +1,8 @@
|
||||
server:
|
||||
port: 8080
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,7 +2,8 @@ server:
|
||||
port: 8080
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright"
|
||||
|
||||
@@ -18,7 +18,7 @@ export default defineConfig({
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
retries: 1,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
@@ -53,8 +53,6 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
name: "light-screenshots",
|
||||
@@ -62,8 +60,6 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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,6 +1,7 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"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"
|
||||
@@ -88,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
|
||||
@@ -250,7 +357,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
{Name: "Test Object", Type: "directory"},
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
|
||||
|
||||
// Delete the old directory item from the database before replacing it with a file
|
||||
idx.DeleteMetadata("/Test Object", true, false)
|
||||
@@ -268,7 +375,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
|
||||
|
||||
// Verify the directory was replaced with a file in the mock data
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
|
||||
@@ -360,7 +467,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
|
||||
|
||||
// Delete the old file item from the database before replacing it with a directory
|
||||
idx.DeleteMetadata("/Test Object", false, false)
|
||||
@@ -376,7 +483,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
Type: "directory",
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
|
||||
|
||||
// Verify the file was replaced with a directory in the mock data
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
|
||||
@@ -577,7 +684,7 @@ func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
// Give scanners time to finish their cleanup (defer blocks in tryAcquireAndScan)
|
||||
// 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)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -25,6 +27,8 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var reDuration = regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
|
||||
|
||||
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
|
||||
var CheckPermissionsFunc = checkPermissionsImpl
|
||||
|
||||
@@ -46,7 +50,7 @@ func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileO
|
||||
wg.Go(func() {
|
||||
fullPath := response.RealPath + "/" + fileItem.Name
|
||||
if isAudio {
|
||||
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
|
||||
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 {
|
||||
@@ -85,7 +89,7 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping).
|
||||
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
|
||||
// Add OnlyOffice ID if applicable
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
@@ -143,7 +147,7 @@ func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *
|
||||
// Layer 1: USER ACCESS CONTROL
|
||||
// Quick check: Does THIS user have permission?
|
||||
if !access.Permitted(idx.Path, indexPath, user.Username) {
|
||||
return "", "", errors.ErrAccessDenied
|
||||
return indexPath, "", errors.ErrAccessDenied
|
||||
}
|
||||
return indexPath, userScope, nil
|
||||
}
|
||||
@@ -153,22 +157,37 @@ type Items struct {
|
||||
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, _, err := CheckPermissions(opts, access, user)
|
||||
if err != nil {
|
||||
return items, err
|
||||
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,
|
||||
})
|
||||
@@ -181,8 +200,15 @@ func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.Use
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -197,16 +223,17 @@ func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.Use
|
||||
// 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)
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, shareStore *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return FileInfoFasterFunc(opts, access, user, shareStore)
|
||||
}
|
||||
|
||||
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
|
||||
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, shareStore *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
indexPath, userScope, err := CheckPermissions(opts, access, user)
|
||||
if err != nil {
|
||||
return response, err
|
||||
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)
|
||||
@@ -219,43 +246,64 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if share != nil && user.Permissions.Share && opts.ShowSharedAttr {
|
||||
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
|
||||
if shareStore != 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)
|
||||
file.IsShared = shareStore.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)
|
||||
folder.IsShared = shareStore.IsShared(response.Path+folder.Name, idx.Path, user.ID)
|
||||
}
|
||||
response.IsShared = share.IsShared(response.Path, idx.Path, user.ID)
|
||||
response.IsShared = shareStore.IsShared(response.Path, idx.Path, user.ID)
|
||||
}
|
||||
if info.Type == "directory" && opts.ShowPinnedItems {
|
||||
if opts.ShareHash != "" && shareStore != nil {
|
||||
if link, err := shareStore.GetByHash(opts.ShareHash); err == nil {
|
||||
if shareRelDir, err := link.ShareRelativeDir(response.Path); err == nil {
|
||||
response.PinnedItems = link.PinnedItems.NamesForDirectory(shareRelDir)
|
||||
}
|
||||
}
|
||||
} else if source, ok := users.ResolveSourceKey(response.Source); ok {
|
||||
response.PinnedItems = user.PinnedNamesForDirectory(source.Path, response.Path)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -307,7 +355,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
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 {
|
||||
@@ -341,10 +389,57 @@ 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))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
matches := reDuration.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
|
||||
@@ -380,6 +475,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 {
|
||||
@@ -412,6 +531,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 {
|
||||
@@ -446,7 +596,7 @@ func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
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
|
||||
@@ -483,7 +633,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
|
||||
@@ -591,7 +741,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)
|
||||
@@ -606,7 +756,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
|
||||
@@ -664,7 +814,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)
|
||||
@@ -675,7 +825,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
|
||||
@@ -762,22 +912,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
|
||||
@@ -790,11 +939,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
|
||||
|
||||
@@ -446,6 +446,7 @@ var types = map[string]string{
|
||||
".ass": "text/subtitle-ass",
|
||||
".vtt": "text/subtitle-vtt",
|
||||
".ssa": "text/subtitle-ssa",
|
||||
".lrc": "text/lyrics",
|
||||
".sbv": "text/plain",
|
||||
".ssi": "text/x-server-parsed-html",
|
||||
".ssm": "application/streamingmedia",
|
||||
|
||||
@@ -25,7 +25,11 @@ func MakeUserDirs(u *users.User, createDir bool) error {
|
||||
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 {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
}
|
||||
source, ok := settings.Config.Server.SourceMap[info.Path]
|
||||
if !ok {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
|
||||
// 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.
|
||||
@@ -23,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 {
|
||||
@@ -58,8 +59,7 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
}
|
||||
// check for OTP for password
|
||||
if user.TOTPSecret != "" {
|
||||
if user.TOTPSecret != "" && !auther.DisableOtp {
|
||||
if totpCode == "" {
|
||||
return nil, errors.ErrNoTotpProvided
|
||||
}
|
||||
|
||||
+102
-7
@@ -1,7 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
@@ -47,14 +52,12 @@ func VerifyExternalJWT(tokenString string, secret string, algorithm string, user
|
||||
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 the secret key for HMAC algorithms
|
||||
// For RSA/ECDSA, this would need to be a public key
|
||||
return []byte(secret), nil
|
||||
|
||||
return jwtVerificationKey(secret, token.Method, algorithm)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -100,10 +103,102 @@ func VerifyExternalJWT(tokenString string, secret string, algorithm string, user
|
||||
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{}:
|
||||
@@ -129,6 +224,6 @@ func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -144,6 +152,140 @@ func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
|
||||
@@ -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
|
||||
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 ---")
|
||||
|
||||
|
||||
+14
-6
@@ -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")
|
||||
@@ -177,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.")
|
||||
}
|
||||
|
||||
@@ -217,6 +220,11 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
// 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
|
||||
}
|
||||
+46
-30
@@ -44,10 +44,12 @@ func validateUserInfo(newDB bool) {
|
||||
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
|
||||
@@ -63,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)
|
||||
}
|
||||
@@ -75,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
|
||||
@@ -94,7 +102,6 @@ func updateUserScopes(user *users.User) bool {
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: src.Path,
|
||||
Scope: existingScope.Scope,
|
||||
@@ -102,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
|
||||
@@ -127,7 +147,6 @@ func updatePermissions(user *users.User) bool {
|
||||
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
|
||||
@@ -169,7 +188,7 @@ func updatePermissions(user *users.User) bool {
|
||||
user.Permissions.Delete = true
|
||||
updateUser = true
|
||||
}
|
||||
user.Version = 2
|
||||
user.Version = users.CurrentUserMigrationVersion
|
||||
if updateUser {
|
||||
createBackup = true
|
||||
}
|
||||
@@ -203,11 +222,11 @@ func updateSidebarLinks(user *users.User) bool {
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
sourceLinksCount++
|
||||
// Check if this source still exists
|
||||
if link.SourceName != "" {
|
||||
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
|
||||
validSourceLinksCount++
|
||||
}
|
||||
if link.SourceName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := users.ResolveSourceKey(link.SourceName); ok {
|
||||
validSourceLinksCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,8 +238,6 @@ 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 {
|
||||
@@ -229,25 +246,24 @@ func updateSidebarLinks(user *users.User) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -262,6 +278,6 @@ func updateTokens(user *users.User) bool {
|
||||
user.Tokens[name] = token
|
||||
}
|
||||
}
|
||||
user.Version = 2
|
||||
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
|
||||
|
||||
@@ -19,11 +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)
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ type LoginMethods struct {
|
||||
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 {
|
||||
@@ -106,15 +107,24 @@ type LdapConfig struct {
|
||||
}
|
||||
|
||||
// JwtAuthConfig configures external JWT token authentication
|
||||
// Similar to Grafana's JWT auth: accepts external JWT tokens signed with a shared secret
|
||||
// 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 for verifying JWT token signatures (required)
|
||||
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
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -39,6 +38,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. "
|
||||
@@ -51,6 +52,7 @@ func Initialize(configFile string) {
|
||||
}
|
||||
setupFs()
|
||||
setupServer()
|
||||
setupHttp()
|
||||
setupAuth(false)
|
||||
setupSources(false)
|
||||
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
|
||||
@@ -60,6 +62,9 @@ func Initialize(configFile string) {
|
||||
}
|
||||
|
||||
func setupServer() {
|
||||
if Config.Server.Socket != "" && (Config.Server.TLSCert != "" || Config.Server.TLSKey != "") {
|
||||
logger.Fatal("server.socket cannot be used with tlsCert or tlsKey")
|
||||
}
|
||||
if Config.Server.ListenAddress == "" {
|
||||
Config.Server.ListenAddress = "0.0.0.0"
|
||||
}
|
||||
@@ -81,6 +86,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)
|
||||
@@ -93,11 +107,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)
|
||||
|
||||
}
|
||||
@@ -462,13 +480,17 @@ func setupAuth(generate bool) {
|
||||
}
|
||||
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]
|
||||
|
||||
}
|
||||
|
||||
@@ -480,31 +502,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 {
|
||||
@@ -542,6 +857,7 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
"integrations": true,
|
||||
"frontend": true,
|
||||
"userDefaults": true,
|
||||
"http": true,
|
||||
}
|
||||
|
||||
filteredConfig := make(map[string]interface{})
|
||||
@@ -657,13 +973,7 @@ func loadEnvConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
func setDefaults(generate bool) Settings {
|
||||
// get number of CPUs available
|
||||
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
|
||||
cpus := runtime.NumCPU()
|
||||
if cpus > 0 && !generate {
|
||||
numCpus = cpus
|
||||
}
|
||||
func SetDefaults(generate bool) Settings {
|
||||
database := os.Getenv("FILEBROWSER_DATABASE")
|
||||
if database == "" {
|
||||
database = "database.db"
|
||||
@@ -674,7 +984,7 @@ func setDefaults(generate bool) Settings {
|
||||
s := Settings{
|
||||
Server: Server{
|
||||
Port: 80,
|
||||
NumImageProcessors: numCpus,
|
||||
NumImageProcessors: 4,
|
||||
BaseURL: "",
|
||||
Database: database,
|
||||
SourceMap: map[string]*Source{},
|
||||
@@ -708,25 +1018,27 @@ func setDefaults(generate bool) Settings {
|
||||
Name: "FileBrowser Quantum",
|
||||
},
|
||||
UserDefaults: UserDefaults{
|
||||
DeleteAfterArchive: true,
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
HideFilesInTree: false,
|
||||
PreferEditorForMarkdown: false,
|
||||
LockPassword: false,
|
||||
ShowHidden: false,
|
||||
DarkMode: boolPtr(true),
|
||||
DisableSettings: false,
|
||||
ViewMode: "normal",
|
||||
Locale: "en",
|
||||
GallerySize: 3,
|
||||
ThemeColor: "var(--blue)",
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Modify: false,
|
||||
Share: false,
|
||||
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{
|
||||
Image: boolPtr(true),
|
||||
@@ -735,15 +1047,50 @@ func setDefaults(generate bool) Settings {
|
||||
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
|
||||
@@ -817,6 +1164,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).
|
||||
}
|
||||
|
||||
// PWAIconsCacheDir is where startup icon generation writes PNGs (under the server cache dir).
|
||||
func PWAIconsCacheDir() string {
|
||||
return filepath.Join(Config.Server.CacheDir, "icons")
|
||||
}
|
||||
|
||||
// Where transient archives for multi-request (Range) downloads are stored to support chunked downloads.
|
||||
func DownloadCacheDir() string {
|
||||
return filepath.Join(Config.Server.CacheDir, "downloads")
|
||||
}
|
||||
|
||||
// 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() {
|
||||
|
||||
@@ -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)
|
||||
@@ -94,6 +94,34 @@ func TestConfigLoadEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadHttpSection(t *testing.T) {
|
||||
testDir := t.TempDir()
|
||||
configContent := []byte(`
|
||||
server:
|
||||
sources:
|
||||
- path: "."
|
||||
http:
|
||||
trustedHeaders:
|
||||
- X-Forwarded-For
|
||||
disableRateLimit: true
|
||||
`)
|
||||
configFile := filepath.Join(testDir, "config.yaml")
|
||||
if err := os.WriteFile(configFile, configContent, 0644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
if err := loadConfigWithDefaults(configFile, true); err != nil {
|
||||
t.Fatalf("error loading config file: %v", err)
|
||||
}
|
||||
|
||||
if len(Config.Http.TrustedHeadersArray) != 1 || Config.Http.TrustedHeadersArray[0] != "X-Forwarded-For" {
|
||||
t.Fatalf("expected trusted headers to contain X-Forwarded-For, got %v", Config.Http.TrustedHeadersArray)
|
||||
}
|
||||
if !Config.Http.DisableRateLimit {
|
||||
t.Fatal("expected http.disableRateLimit to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadSpecificValues(t *testing.T) {
|
||||
// Create isolated test directory
|
||||
testDir := t.TempDir()
|
||||
@@ -106,7 +134,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)
|
||||
|
||||
@@ -286,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
|
||||
@@ -479,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
|
||||
@@ -516,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
|
||||
}
|
||||
@@ -790,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
|
||||
}
|
||||
@@ -995,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
|
||||
}
|
||||
@@ -1030,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)
|
||||
@@ -1042,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
|
||||
}
|
||||
@@ -1072,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)
|
||||
@@ -1108,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
|
||||
}
|
||||
@@ -1201,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,69 +57,117 @@ 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
|
||||
u.HideFilesInTree = Config.UserDefaults.HideFilesInTree
|
||||
u.DeleteAfterArchive = Config.UserDefaults.DeleteAfterArchive
|
||||
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.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
|
||||
u.Preview.Audio = boolValueOrDefault(Config.UserDefaults.Preview.Audio, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
|
||||
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
|
||||
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
|
||||
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
|
||||
u.Preview.Models = boolValueOrDefault(Config.UserDefaults.Preview.Models, true)
|
||||
// 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
|
||||
|
||||
u.ShowHidden = Config.UserDefaults.ShowHidden
|
||||
u.DateFormat = Config.UserDefaults.DateFormat
|
||||
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
|
||||
u.ThemeColor = Config.UserDefaults.ThemeColor
|
||||
u.GallerySize = Config.UserDefaults.GallerySize
|
||||
u.QuickDownload = Config.UserDefaults.QuickDownload
|
||||
u.LockPassword = Config.UserDefaults.LockPassword
|
||||
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
|
||||
u.FileLoading = Config.UserDefaults.FileLoading
|
||||
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
|
||||
u.PreferEditorForMarkdown = Config.UserDefaults.PreferEditorForMarkdown
|
||||
if len(u.Scopes) == 0 && u.Username != "anonymous" {
|
||||
// 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)
|
||||
}
|
||||
|
||||
if len(u.Scopes) == 0 {
|
||||
for _, source := range Config.Server.Sources {
|
||||
if source.Config.DefaultEnabled {
|
||||
u.Scopes = append(u.Scopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: source.Config.DefaultUserScope,
|
||||
})
|
||||
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,17 +44,13 @@ 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 {
|
||||
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
|
||||
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
|
||||
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
|
||||
Socket string `json:"socket"` // socket to listen on
|
||||
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is 4.
|
||||
Socket string `json:"socket"` // socket to listen on - eg. /var/run/filebrowser.sock
|
||||
TLSKey string `json:"tlsKey"` // path to TLS key
|
||||
TLSCert string `json:"tlsCert"` // path to TLS cert
|
||||
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
|
||||
@@ -195,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
|
||||
@@ -207,14 +211,14 @@ 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
|
||||
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
|
||||
CreateUserDir bool `json:"createUserDir"` // deprecated: create a user directory for each user under defaultUserScope + username (always true now)
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
|
||||
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
// hidden but used internally - optimized map lookups for conditional rules
|
||||
ResolvedRules ResolvedRulesConfig `json:"-"`
|
||||
@@ -312,24 +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"` // deprecated: always true in v1.3.0+
|
||||
Image *bool `json:"image"` // show thumbnails for image files
|
||||
Video *bool `json:"video"` // show thumbnails for video files
|
||||
Audio *bool `json:"audio"` // show thumbnails for audio files
|
||||
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
Office *bool `json:"office"` // show thumbnails for office files
|
||||
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
|
||||
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
|
||||
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
|
||||
Models *bool `json:"models"` // show live thumbnails for 3D models files
|
||||
}
|
||||
|
||||
// UserDefaultsPermissions holds permission settings with pointer types for defaults
|
||||
type UserDefaultsPermissions struct {
|
||||
Api bool `json:"api"` // 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
|
||||
@@ -340,40 +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
|
||||
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
|
||||
Preview UserDefaultsPreview `json:"preview"`
|
||||
// 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
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer.
|
||||
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
|
||||
}
|
||||
@@ -3,11 +3,29 @@ 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 = ""
|
||||
|
||||
@@ -44,3 +62,13 @@ func GenerateKey() string {
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -57,6 +57,9 @@ type FileOptions struct {
|
||||
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)
|
||||
ShowPinnedItems bool // whether to show pinned items
|
||||
ShareHash string // when set, pinned items are loaded from this share link
|
||||
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
|
||||
@@ -86,3 +89,25 @@ func SanitizeUserPath(userPath string) (string, error) {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|warning|error"
|
||||
sources:
|
||||
- path: "/srv"
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
audio: true
|
||||
motionVideoPreview: true
|
||||
office: true
|
||||
popup: true
|
||||
folder: true
|
||||
models: true
|
||||
File renamed without changes.
+69
-38
@@ -5,51 +5,82 @@ server:
|
||||
- levels: "info|warning|error"
|
||||
sources:
|
||||
- path: "/srv"
|
||||
auth:
|
||||
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
|
||||
hideFilesInTree: false # hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
darkMode: true # should dark mode be enabled (defaults to true if not set)
|
||||
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
|
||||
singleClick: false # open directory on single click, also enables middle click to open in new tab
|
||||
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
|
||||
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
|
||||
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
quickDownload: false # show icon to download in one click
|
||||
disablePreviewExt: "" # space separated list of file extensions to disable preview for
|
||||
disableViewingExt: "" # space separated list of file extensions to disable viewing for
|
||||
lockPassword: false # disable the user from changing their password
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
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
|
||||
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)
|
||||
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
admin: false # allow admin access
|
||||
modify: false # allow modifying files
|
||||
share: false # allow sharing files
|
||||
realtime: false # allow realtime updates
|
||||
delete: false # allow deleting files
|
||||
create: false # allow creating or uploading files
|
||||
download: true # allow downloading files (defaults to true if not set)
|
||||
disableUpdateNotifications: false # disable update notifications banner for admin users
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
|
||||
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config.
|
||||
showSelectMultiple: false # show select multiple files on desktop
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
|
||||
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
)
|
||||
|
||||
// PinnedItems maps share-relative directory paths to pinned item names.
|
||||
type PinnedItems map[string][]string
|
||||
|
||||
// Add records a pinned item name under shareRelDir.
|
||||
func (p PinnedItems) Add(shareRelDir, name string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
items := p[shareRelDir]
|
||||
if slices.Contains(items, name) {
|
||||
return
|
||||
}
|
||||
p[shareRelDir] = append(items, name)
|
||||
}
|
||||
|
||||
// Remove deletes a pinned item name and prunes empty map entries.
|
||||
func (p PinnedItems) Remove(shareRelDir, name string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
items := p[shareRelDir]
|
||||
idx := slices.Index(items, name)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
items = append(items[:idx], items[idx+1:]...)
|
||||
if len(items) == 0 {
|
||||
delete(p, shareRelDir)
|
||||
} else {
|
||||
p[shareRelDir] = items
|
||||
}
|
||||
}
|
||||
|
||||
// NamesForDirectory returns pinned item names for a share-relative directory path.
|
||||
func (p PinnedItems) NamesForDirectory(shareRelDir string) []string {
|
||||
if len(p) == 0 {
|
||||
return nil
|
||||
}
|
||||
names := p[shareRelDir]
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(names))
|
||||
copy(out, names)
|
||||
return out
|
||||
}
|
||||
|
||||
// EnsurePinnedItems returns a non-nil map for mutation.
|
||||
func (l *Link) EnsurePinnedItems() PinnedItems {
|
||||
if l.PinnedItems == nil {
|
||||
l.PinnedItems = make(PinnedItems)
|
||||
}
|
||||
return l.PinnedItems
|
||||
}
|
||||
|
||||
// ShareRelativeDir maps a source index directory path to a share-relative directory path.
|
||||
// link.Path is the index path of the share root (e.g. "/share/").
|
||||
func (l *Link) ShareRelativeDir(indexDirPath string) (string, error) {
|
||||
shareRootIndex := utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
indexDirPath = utils.AddTrailingSlashIfNotExists(indexDirPath)
|
||||
if !strings.HasPrefix(indexDirPath, shareRootIndex) {
|
||||
return "", fmt.Errorf("path is outside share scope")
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(indexDirPath, shareRootIndex)
|
||||
if rel == "" {
|
||||
return "/", nil
|
||||
}
|
||||
return utils.AddTrailingSlashIfNotExists("/" + strings.TrimPrefix(rel, "/")), nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package share
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSharePinnedItemsAddRemove(t *testing.T) {
|
||||
p := make(PinnedItems)
|
||||
const dir = "/"
|
||||
const name = "vacation.jpg"
|
||||
|
||||
p.Add(dir, name)
|
||||
if len(p[dir]) != 1 || p[dir][0] != name {
|
||||
t.Fatalf("add failed: %#v", p)
|
||||
}
|
||||
|
||||
p.Add(dir, name)
|
||||
if len(p[dir]) != 1 {
|
||||
t.Fatalf("duplicate add should be ignored: %#v", p)
|
||||
}
|
||||
|
||||
p.Remove(dir, name)
|
||||
if len(p) != 0 {
|
||||
t.Fatalf("remove should prune empty entries: %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharePinnedNamesForDirectory(t *testing.T) {
|
||||
p := PinnedItems{
|
||||
"/photos/": {"vacation.jpg", "notes.txt"},
|
||||
}
|
||||
|
||||
names := p.NamesForDirectory("/photos/")
|
||||
if len(names) != 2 || names[0] != "vacation.jpg" || names[1] != "notes.txt" {
|
||||
t.Fatalf("unexpected names: %#v", names)
|
||||
}
|
||||
if got := p.NamesForDirectory("/missing/"); got != nil {
|
||||
t.Fatalf("expected nil for missing directory, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareRelativeDir(t *testing.T) {
|
||||
link := &Link{CommonShare: CommonShare{Path: "/share/"}}
|
||||
|
||||
got, err := link.ShareRelativeDir("/share/")
|
||||
if err != nil {
|
||||
t.Fatalf("ShareRelativeDir root: %v", err)
|
||||
}
|
||||
if got != "/" {
|
||||
t.Fatalf("got %q, want /", got)
|
||||
}
|
||||
|
||||
got, err = link.ShareRelativeDir("/share/test [file2]/")
|
||||
if err != nil {
|
||||
t.Fatalf("ShareRelativeDir subdir: %v", err)
|
||||
}
|
||||
if got != "/test [file2]/" {
|
||||
t.Fatalf("got %q, want /test [file2]/", got)
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,9 @@ 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"` // 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"`
|
||||
}
|
||||
@@ -59,11 +60,12 @@ type CreateBody struct {
|
||||
// Link is the information needed to build a shareable link.
|
||||
type Link struct {
|
||||
CommonShare
|
||||
Downloads int `json:"downloads"`
|
||||
Hash string `json:"hash" storm:"id,index"`
|
||||
UserID uint `json:"userID"`
|
||||
Expire int64 `json:"expire"`
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
Hash string `json:"hash" storm:"id,index"`
|
||||
UserID uint `json:"userID"`
|
||||
Expire int64 `json:"expire"`
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
PinnedItems PinnedItems `json:"pinnedItems,omitempty"`
|
||||
// Token is a random value that will only be set when PasswordHash is set. It is
|
||||
// URL-Safe and is used to download links in password-protected shares via a
|
||||
// query arg.
|
||||
|
||||
@@ -298,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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -336,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 {
|
||||
@@ -363,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)
|
||||
@@ -474,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 := `
|
||||
@@ -1077,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
|
||||
@@ -1320,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
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,31 @@ 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, caseExact bool) (string, []interface{}) {
|
||||
if len(patterns) == 0 {
|
||||
return query, args
|
||||
}
|
||||
nameExpr := "name"
|
||||
if !caseExact {
|
||||
nameExpr = "lower(name)"
|
||||
}
|
||||
parts := make([]string, len(patterns))
|
||||
for i := range patterns {
|
||||
parts[i] = nameExpr + " 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, caseExact bool) (*sql.Rows, error) {
|
||||
query := `
|
||||
SELECT path, name, size, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
@@ -35,6 +57,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, caseExact)
|
||||
}
|
||||
|
||||
if largest {
|
||||
query += " ORDER BY size DESC"
|
||||
}
|
||||
@@ -43,8 +69,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, caseExact bool) (*sql.Rows, error) {
|
||||
if len(sources) == 0 {
|
||||
return nil, fmt.Errorf("at least one source is required")
|
||||
}
|
||||
@@ -95,6 +122,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, caseExact)
|
||||
}
|
||||
|
||||
if largest {
|
||||
query += " ORDER BY size DESC"
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func (s settingsBackend) Save(set *settings.Settings) error {
|
||||
func (s settingsBackend) GetServer() (*settings.Server, error) {
|
||||
server := &settings.Server{
|
||||
Port: 80,
|
||||
NumImageProcessors: 1,
|
||||
NumImageProcessors: 4,
|
||||
}
|
||||
return server, get(s.db, "server", server)
|
||||
}
|
||||
|
||||
@@ -74,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
|
||||
}
|
||||
@@ -86,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 {
|
||||
@@ -246,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()
|
||||
@@ -265,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -492,13 +492,13 @@ func TestFilterRestrictedFields(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "all allowed fields",
|
||||
fields: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
|
||||
expected: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
|
||||
fields: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode", "PinnedItems"},
|
||||
expected: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode", "PinnedItems"},
|
||||
},
|
||||
{
|
||||
name: "mixed allowed and restricted",
|
||||
fields: []string{"DarkMode", "Permissions", "Scopes", "OtpEnabled"},
|
||||
expected: []string{"DarkMode", "OtpEnabled"},
|
||||
fields: []string{"DarkMode", "Permissions", "Scopes", "OtpEnabled", "PinnedItems"},
|
||||
expected: []string{"DarkMode", "OtpEnabled", "PinnedItems"},
|
||||
},
|
||||
{
|
||||
name: "all restricted fields",
|
||||
|
||||
@@ -92,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)
|
||||
@@ -130,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 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 strings.HasPrefix(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, "/") {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package users
|
||||
|
||||
import "slices"
|
||||
|
||||
// Add records a pinned item name under sourcePath and indexDirPath.
|
||||
func (p PinnedItems) Add(sourcePath, indexDirPath, name string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p[sourcePath] == nil {
|
||||
p[sourcePath] = make(map[string][]string)
|
||||
}
|
||||
items := p[sourcePath][indexDirPath]
|
||||
if slices.Contains(items, name) {
|
||||
return
|
||||
}
|
||||
p[sourcePath][indexDirPath] = append(items, name)
|
||||
}
|
||||
|
||||
// Remove deletes a pinned item name and prunes empty map entries.
|
||||
func (p PinnedItems) Remove(sourcePath, indexDirPath, name string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
byDir, ok := p[sourcePath]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items := byDir[indexDirPath]
|
||||
idx := slices.Index(items, name)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
items = append(items[:idx], items[idx+1:]...)
|
||||
if len(items) == 0 {
|
||||
delete(byDir, indexDirPath)
|
||||
} else {
|
||||
byDir[indexDirPath] = items
|
||||
}
|
||||
if len(byDir) == 0 {
|
||||
delete(p, sourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
// PinnedNamesForDirectory returns pinned item names for a source/index directory path.
|
||||
func (u *User) PinnedNamesForDirectory(sourcePath, indexDirPath string) []string {
|
||||
if u == nil || len(u.PinnedItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
byDir, ok := u.PinnedItems[sourcePath]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
names := byDir[indexDirPath]
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(names))
|
||||
copy(out, names)
|
||||
return out
|
||||
}
|
||||
|
||||
// EnsurePinnedItems returns a non-nil map for mutation.
|
||||
func (u *User) EnsurePinnedItems() PinnedItems {
|
||||
if u.PinnedItems == nil {
|
||||
u.PinnedItems = make(PinnedItems)
|
||||
}
|
||||
return u.PinnedItems
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package users
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPinnedItemsAddRemove(t *testing.T) {
|
||||
p := make(PinnedItems)
|
||||
const source = "/data/files"
|
||||
const dir = "/photos/"
|
||||
const name = "vacation.jpg"
|
||||
|
||||
p.Add(source, dir, name)
|
||||
if len(p[source][dir]) != 1 || p[source][dir][0] != name {
|
||||
t.Fatalf("add failed: %#v", p)
|
||||
}
|
||||
|
||||
p.Add(source, dir, name)
|
||||
if len(p[source][dir]) != 1 {
|
||||
t.Fatalf("duplicate add should be ignored: %#v", p)
|
||||
}
|
||||
|
||||
p.Remove(source, dir, name)
|
||||
if len(p) != 0 {
|
||||
t.Fatalf("remove should prune empty maps: %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedNamesForDirectory(t *testing.T) {
|
||||
u := &User{
|
||||
NonAdminEditable: NonAdminEditable{
|
||||
PinnedItems: PinnedItems{
|
||||
"/data/files": {
|
||||
"/photos/": {"vacation.jpg", "notes.txt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
names := u.PinnedNamesForDirectory("/data/files", "/photos/")
|
||||
if len(names) != 2 || names[0] != "vacation.jpg" || names[1] != "notes.txt" {
|
||||
t.Fatalf("unexpected names: %#v", names)
|
||||
}
|
||||
if got := u.PinnedNamesForDirectory("/missing", "/photos/"); got != nil {
|
||||
t.Fatalf("expected nil for missing source, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedItemsRemoveMissing(t *testing.T) {
|
||||
p := make(PinnedItems)
|
||||
p.Remove("/data/files", "/photos/", "vacation.jpg")
|
||||
if len(p) != 0 {
|
||||
t.Fatalf("remove on empty map should be no-op: %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedItemsMultipleItems(t *testing.T) {
|
||||
p := make(PinnedItems)
|
||||
const source = "/data/files"
|
||||
const dir = "/photos/"
|
||||
|
||||
p.Add(source, dir, "a.jpg")
|
||||
p.Add(source, dir, "b.jpg")
|
||||
p.Add(source, dir, "c.jpg")
|
||||
|
||||
if len(p[source][dir]) != 3 {
|
||||
t.Fatalf("expected 3 items, got %d", len(p[source][dir]))
|
||||
}
|
||||
|
||||
p.Remove(source, dir, "b.jpg")
|
||||
items := p[source][dir]
|
||||
if len(items) != 2 || items[0] != "a.jpg" || items[1] != "c.jpg" {
|
||||
t.Fatalf("unexpected items after remove: %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedItemsMultipleDirectories(t *testing.T) {
|
||||
p := make(PinnedItems)
|
||||
const source = "/data/files"
|
||||
const photosDir = "/photos/"
|
||||
const docsDir = "/docs/"
|
||||
|
||||
p.Add(source, photosDir, "a.jpg")
|
||||
p.Add(source, docsDir, "notes.txt")
|
||||
|
||||
if len(p[source][photosDir]) != 1 || p[source][photosDir][0] != "a.jpg" {
|
||||
t.Fatalf("unexpected photos dir: %#v", p[source][photosDir])
|
||||
}
|
||||
if len(p[source][docsDir]) != 1 || p[source][docsDir][0] != "notes.txt" {
|
||||
t.Fatalf("unexpected docs dir: %#v", p[source][docsDir])
|
||||
}
|
||||
|
||||
p.Remove(source, photosDir, "a.jpg")
|
||||
if _, ok := p[source][photosDir]; ok {
|
||||
t.Fatalf("photos dir should be pruned: %#v", p)
|
||||
}
|
||||
if len(p[source][docsDir]) != 1 {
|
||||
t.Fatalf("docs dir should remain: %#v", p[source][docsDir])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +20,35 @@ const (
|
||||
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"` // for backward compatibility
|
||||
@@ -66,7 +96,11 @@ type Preview struct {
|
||||
Models bool `json:"models"` // show live thumbnails for 3D models files
|
||||
}
|
||||
|
||||
// User describes a user.
|
||||
// source filesystem path -> index directory path -> pinned item names (unique within the directory)
|
||||
type PinnedItems map[string]map[string][]string
|
||||
|
||||
// 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"`
|
||||
@@ -83,51 +117,55 @@ type User struct {
|
||||
LoginMethod LoginMethod `json:"loginMethod"`
|
||||
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
|
||||
Version int `json:"version"`
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
// legacy for migration purposes... og filebrowser has perm attribute
|
||||
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
|
||||
type NonAdminEditable 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
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
Preview Preview `json:"preview"`
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Password string `json:"password,omitempty"`
|
||||
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
|
||||
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
|
||||
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
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
|
||||
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
|
||||
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
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
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
|
||||
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
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
Preview Preview `json:"preview"`
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Password string `json:"password,omitempty"`
|
||||
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
|
||||
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
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
|
||||
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
|
||||
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
|
||||
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"`
|
||||
PinnedItems PinnedItems `json:"pinnedItems,omitempty"`
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
@@ -143,7 +181,7 @@ type SidebarLink struct {
|
||||
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 {
|
||||
@@ -190,6 +228,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()
|
||||
}
|
||||
+77
-65
@@ -4,40 +4,42 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/coreos/go-oidc/v3 v3.20.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-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/gen2brain/go-fitz v1.28.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.14
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
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.21
|
||||
github.com/mattn/go-sqlite3 v1.14.42
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/kovidgoyal/imaging v1.8.23
|
||||
github.com/mattn/go-sqlite3 v1.14.48
|
||||
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.50.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/mod v0.38.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.43.0
|
||||
golang.org/x/tools v0.48.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.48.2
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
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
|
||||
@@ -50,15 +52,16 @@ 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.0 // 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/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.65.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||
github.com/air-verse/air v1.65.3 // 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.1.0 // indirect
|
||||
@@ -66,64 +69,67 @@ require (
|
||||
github.com/alingse/asasalint v0.0.11 // indirect
|
||||
github.com/alingse/nilnesserr v0.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
|
||||
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
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.6.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.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/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.10.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.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.20 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // 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.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.5 // 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.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/spec v0.22.6 // indirect
|
||||
github.com/go-openapi/swag/conv v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.27.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.27.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
|
||||
@@ -134,24 +140,28 @@ require (
|
||||
github.com/go-toolsmith/strparse v1.1.0 // indirect
|
||||
github.com/go-toolsmith/typep v1.1.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // 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.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-20260401045841-3289a7742193 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20260713102120-857a528af641 // 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.11.4 // 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
|
||||
@@ -159,17 +169,15 @@ 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.6.0 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.10.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
@@ -186,31 +194,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.21 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // 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.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.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
|
||||
@@ -228,16 +237,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/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.24.8-0.20260309165252-619ce2117e08 // 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.5.1 // indirect
|
||||
github.com/sourcegraph/go-diff v0.7.0 // 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
|
||||
@@ -249,16 +259,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.1 // indirect
|
||||
github.com/uudashr/iface v1.4.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
|
||||
@@ -266,22 +278,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-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.etcd.io/bbolt v1.5.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-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.40.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.7.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/libc v1.74.2 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
|
||||
+174
-196
@@ -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,38 +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.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
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.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
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.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
|
||||
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.65.0 h1:MCJTmXL+UBS1Ji05jsIaUBXokeW10W+Y6ODkx6GcfZk=
|
||||
github.com/air-verse/air v1.65.0/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.65.3 h1:7NUAqofZuSf5vZbVoZpdTr/4LuKkOCj1JTiMrOdUs7A=
|
||||
github.com/air-verse/air v1.65.3/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.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
github.com/alecthomas/chroma/v2 v2.23.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=
|
||||
@@ -104,8 +104,6 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
|
||||
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.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
|
||||
github.com/alexkohler/prealloc v1.0.2/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=
|
||||
@@ -120,12 +118,10 @@ github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtS
|
||||
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=
|
||||
@@ -166,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.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
|
||||
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
|
||||
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=
|
||||
@@ -175,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=
|
||||
@@ -190,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.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
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=
|
||||
@@ -208,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.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-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||
github.com/coreos/go-oidc/v3 v3.20.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=
|
||||
@@ -233,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=
|
||||
@@ -254,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.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/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=
|
||||
@@ -264,8 +266,6 @@ 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=
|
||||
@@ -277,20 +277,22 @@ 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=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
|
||||
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
|
||||
github.com/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJtW1M=
|
||||
github.com/gen2brain/go-fitz v1.28.2/go.mod h1:pY2hqAjp9Zy7qfPI2gwbJMHBFAdZpVXOLrRxD82l3Bs=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/ghostiam/protogetter v0.3.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-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/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=
|
||||
@@ -307,48 +309,48 @@ github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
|
||||
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-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
|
||||
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
|
||||
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.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
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/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
||||
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
|
||||
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
|
||||
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
|
||||
github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic=
|
||||
github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8=
|
||||
github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8=
|
||||
github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE=
|
||||
github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk=
|
||||
github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE=
|
||||
github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0 h1:+d7C7Ur/SsGg/UZ9G0JEovnfRqtMNZCJQGKc2h/ojoE=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
|
||||
github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI=
|
||||
github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4=
|
||||
github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio=
|
||||
github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
|
||||
github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o=
|
||||
github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM=
|
||||
github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
|
||||
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
|
||||
github.com/go-openapi/testify/v2 v2.6.0/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.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
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=
|
||||
@@ -375,6 +377,10 @@ github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUN
|
||||
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
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=
|
||||
@@ -406,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-20260401045841-3289a7742193 h1:TWcQGetTogk7h5+TRKI6dnvXRQCtEdSPXLu4RdouuM8=
|
||||
github.com/golang/geo v0.0.0-20260401045841-3289a7742193/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20260713102120-857a528af641 h1:cpoobkgVGCE6bC5kJkOfwfM6hAtZuvoBjnCLECxG4AU=
|
||||
github.com/golang/geo v0.0.0-20260713102120-857a528af641/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=
|
||||
@@ -444,16 +452,14 @@ 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.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
|
||||
github.com/golangci/golangci-lint/v2 v2.11.4 h1:GK+UlZBN5y7rh2PBnHA93XLSX6RaF7uhzJQ3JwU1wuA=
|
||||
github.com/golangci/golangci-lint/v2 v2.11.4/go.mod h1:ODQDCASMA3VqfZYIbbQLpTRTzV7O/vjmIRF6u8NyFwI=
|
||||
github.com/golangci/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=
|
||||
@@ -462,6 +468,8 @@ github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3H
|
||||
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=
|
||||
@@ -481,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=
|
||||
@@ -516,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=
|
||||
@@ -525,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=
|
||||
@@ -554,10 +566,8 @@ 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=
|
||||
@@ -575,12 +585,8 @@ 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.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
|
||||
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
|
||||
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
|
||||
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
|
||||
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
@@ -592,8 +598,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.21 h1:95S2+dowTeKJJHNpf6lnScvIennTr2H0zQotu+ptNQw=
|
||||
github.com/kovidgoyal/imaging v1.8.21/go.mod h1:976F+zjiQeZ7sd87Pxlm0a64S/w9bImSIWg3sSk1rdQ=
|
||||
github.com/kovidgoyal/imaging v1.8.23 h1:4WsboyQ/E8tic2kX49P93Rvscg33SPsCwTBG5EZZF54=
|
||||
github.com/kovidgoyal/imaging v1.8.23/go.mod h1:k3Iot3H0v2rSWXIxQISfT3e9wIryqzAHLHCuhG7z9lM=
|
||||
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=
|
||||
@@ -626,8 +632,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=
|
||||
@@ -638,8 +644,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=
|
||||
@@ -652,16 +658,14 @@ 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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
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.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
|
||||
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
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.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
|
||||
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
|
||||
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=
|
||||
@@ -679,10 +683,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=
|
||||
@@ -707,8 +711,8 @@ 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.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
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=
|
||||
@@ -722,10 +726,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=
|
||||
@@ -770,7 +776,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=
|
||||
@@ -783,8 +788,8 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
|
||||
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
|
||||
github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU=
|
||||
github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ=
|
||||
github.com/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=
|
||||
@@ -795,14 +800,10 @@ 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.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
|
||||
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
|
||||
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
|
||||
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
|
||||
github.com/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=
|
||||
@@ -811,12 +812,10 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
|
||||
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
|
||||
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
|
||||
github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
|
||||
github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
|
||||
github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
|
||||
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
|
||||
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
|
||||
github.com/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=
|
||||
@@ -862,14 +861,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=
|
||||
@@ -880,16 +881,16 @@ github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSW
|
||||
github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
|
||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA=
|
||||
github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU=
|
||||
github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
|
||||
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
|
||||
github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
|
||||
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
|
||||
github.com/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=
|
||||
@@ -906,7 +907,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=
|
||||
@@ -919,15 +919,15 @@ 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-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=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
@@ -935,6 +935,8 @@ 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/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=
|
||||
@@ -948,10 +950,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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
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=
|
||||
@@ -970,8 +970,8 @@ golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjO
|
||||
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.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/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=
|
||||
@@ -991,14 +991,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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
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=
|
||||
@@ -1031,18 +1028,14 @@ 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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
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=
|
||||
@@ -1064,10 +1057,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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.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=
|
||||
@@ -1103,9 +1094,7 @@ 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=
|
||||
@@ -1116,17 +1105,11 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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.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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.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=
|
||||
@@ -1135,10 +1118,8 @@ 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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
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=
|
||||
@@ -1188,14 +1169,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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
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=
|
||||
@@ -1281,8 +1259,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=
|
||||
@@ -1312,30 +1290,30 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
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.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/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.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/libc v1.74.2 h1:r94QPC+Mh3WN1lSCDxQxFdIxcI+zdQROo+1pTEZGqkk=
|
||||
modernc.org/libc v1.74.2/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
|
||||
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.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
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=
|
||||
|
||||
+20
-4
@@ -147,6 +147,22 @@ type AuthTokenFrontend struct {
|
||||
Permissions users.Permissions `json:"Permissions,omitempty"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -169,8 +185,8 @@ func listApiTokensHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
AuthTokensFrontend = append(AuthTokensFrontend, AuthTokenFrontend{
|
||||
Token: token.Token,
|
||||
Name: name,
|
||||
IssuedAt: token.RegisteredClaims.IssuedAt.Unix(),
|
||||
ExpiresAt: token.RegisteredClaims.ExpiresAt.Unix(),
|
||||
IssuedAt: authTokenIssuedUnix(token),
|
||||
ExpiresAt: authTokenExpiresUnix(token),
|
||||
Permissions: token.Permissions,
|
||||
})
|
||||
}
|
||||
@@ -205,8 +221,8 @@ func getApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
AuthTokenFrontendResponse := AuthTokenFrontend{
|
||||
Token: tokenInfo.Token,
|
||||
Name: name,
|
||||
IssuedAt: tokenInfo.IssuedAt,
|
||||
ExpiresAt: tokenInfo.ExpiresAt,
|
||||
IssuedAt: authTokenIssuedUnix(tokenInfo),
|
||||
ExpiresAt: authTokenExpiresUnix(tokenInfo),
|
||||
Permissions: tokenInfo.Permissions,
|
||||
}
|
||||
return renderJSON(w, r, AuthTokenFrontendResponse)
|
||||
|
||||
+405
-72
@@ -11,18 +11,162 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// archiveMultiRequestIdle is how long without another Range/HEAD on the same archiveToken before
|
||||
// the spooled file is removed (abandoned chunked download). Each chunk request extends this window.
|
||||
const archiveMultiRequestIdle = 5 * time.Minute
|
||||
|
||||
// archiveSpoolPathCache maps archiveToken -> temp file path on disk for chunked archive downloads.
|
||||
var archiveSpoolPathCache = cache.NewCache[string](archiveMultiRequestIdle)
|
||||
|
||||
// archiveSpoolIdleTimers implements a sliding idle deadline per token so temp files are deleted
|
||||
// after abandonment; the in-process cache alone does not remove on-disk spool files.
|
||||
var (
|
||||
archiveSpoolIdleMu sync.Mutex
|
||||
archiveSpoolIdleTimers = make(map[string]*time.Timer) // guarded by archiveSpoolIdleMu
|
||||
)
|
||||
|
||||
func randomArchiveToken() (string, error) {
|
||||
return utils.RandomHex(16)
|
||||
}
|
||||
|
||||
func stopArchiveSpoolIdleTimer(token string) {
|
||||
archiveSpoolIdleMu.Lock()
|
||||
t, ok := archiveSpoolIdleTimers[token]
|
||||
if ok {
|
||||
delete(archiveSpoolIdleTimers, token)
|
||||
}
|
||||
archiveSpoolIdleMu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !t.Stop() {
|
||||
select {
|
||||
case <-t.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeSpooledArchiveNow drops token state and deletes the temp file (idle timeout or final chunk served).
|
||||
func removeSpooledArchiveNow(token, tmpPath string) {
|
||||
stopArchiveSpoolIdleTimer(token)
|
||||
archiveSpoolPathCache.Delete(token)
|
||||
if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) {
|
||||
logger.Debugf("archive spool remove %s: %v", tmpPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func rescheduleArchiveSpoolIdleCleanup(token, tmpPath string) {
|
||||
stopArchiveSpoolIdleTimer(token)
|
||||
timer := time.AfterFunc(archiveMultiRequestIdle, func() {
|
||||
removeSpooledArchiveNow(token, tmpPath)
|
||||
})
|
||||
archiveSpoolIdleMu.Lock()
|
||||
archiveSpoolIdleTimers[token] = timer
|
||||
archiveSpoolIdleMu.Unlock()
|
||||
}
|
||||
|
||||
// archiveGetDeliversThroughEOF reports whether this GET serves through the last byte (full body or Range that ends at size-1).
|
||||
func archiveGetDeliversThroughEOF(r *http.Request, size int64) bool {
|
||||
if r.Method != http.MethodGet || size <= 0 {
|
||||
return false
|
||||
}
|
||||
rg := r.Header.Get("Range")
|
||||
if rg == "" {
|
||||
return true
|
||||
}
|
||||
const prefix = "bytes="
|
||||
if !strings.HasPrefix(rg, prefix) {
|
||||
return false
|
||||
}
|
||||
rg = strings.TrimSpace(rg[len(prefix):])
|
||||
if i := strings.IndexByte(rg, ','); i >= 0 {
|
||||
rg = rg[:i]
|
||||
}
|
||||
dash := strings.IndexByte(rg, '-')
|
||||
if dash < 0 {
|
||||
return false
|
||||
}
|
||||
startStr := strings.TrimSpace(rg[:dash])
|
||||
endStr := strings.TrimSpace(rg[dash+1:])
|
||||
if endStr == "" {
|
||||
// "bytes=N-": suffix through EOF
|
||||
return true
|
||||
}
|
||||
if startStr == "" && endStr != "" {
|
||||
// "bytes=-N": last-N suffix includes EOF when size > 0
|
||||
return true
|
||||
}
|
||||
end, err := strconv.ParseInt(endStr, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return end >= size-1
|
||||
}
|
||||
|
||||
// archiveAttachmentStem is the filename stem (no extension) for Content-Disposition on multi-item /
|
||||
// directory downloads. It prefers the client file path so names match the UI (e.g. "Desktop.zip"); the
|
||||
// old filepath.Dir(realPath)+Base trick can yield "/" at filesystem root or the wrong parent if index metadata lags.
|
||||
func archiveAttachmentStem(fileList []string, realPath string) string {
|
||||
if len(fileList) == 0 {
|
||||
return "download"
|
||||
}
|
||||
first := filepath.ToSlash(strings.TrimSuffix(strings.TrimSpace(fileList[0]), "/"))
|
||||
var stem string
|
||||
if len(fileList) == 1 {
|
||||
stem = path.Base(first)
|
||||
} else {
|
||||
stem = path.Base(path.Dir(first))
|
||||
}
|
||||
if stem == "" || stem == "." || stem == "/" {
|
||||
if len(fileList) == 1 {
|
||||
stem = filepath.Base(realPath)
|
||||
} else {
|
||||
stem = filepath.Base(filepath.Dir(realPath))
|
||||
}
|
||||
}
|
||||
if stem == "" || stem == "." || stem == "/" {
|
||||
return "download"
|
||||
}
|
||||
return stem
|
||||
}
|
||||
|
||||
// serveArchiveWithServeContent sends a built archive using ServeContent (Range-capable).
|
||||
// For share links with MaxBandwidth > 0, outbound data is throttled via newThrottledReadSeeker (same limit as single-file download).
|
||||
func serveArchiveWithServeContent(w http.ResponseWriter, r *http.Request, d *requestContext, rs io.ReadSeeker, fi os.FileInfo, originalFileName string) (int, error) {
|
||||
setContentDisposition(w, r, originalFileName)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
reader := rs
|
||||
if d.share != nil && d.share.MaxBandwidth > 0 {
|
||||
limit := rate.Limit(d.share.MaxBandwidth * 1024)
|
||||
burst := d.share.MaxBandwidth * 1024
|
||||
reader = newThrottledReadSeeker(rs, limit, burst, r.Context())
|
||||
}
|
||||
http.ServeContent(w, r, originalFileName, fi.ModTime(), reader)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// archiveCreateHandler creates an archive on the server at the given destination.
|
||||
// POST /resources/archive — server-side only; does not return archive data.
|
||||
//
|
||||
@@ -103,34 +247,34 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullDest := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDest, d.user.Username) {
|
||||
fullDestFile := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestFile, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
// Destination is a file we are about to create; resolve parent dir only (parent must exist or be creatable).
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
fullDestParent := utils.GetParentDirectoryPath(fullDestFile)
|
||||
if store.Access != nil && fullDestParent != fullDestFile && !store.Access.Permitted(idxTo.Path, fullDestParent, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination parent of %s", req.Destination)
|
||||
}
|
||||
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
|
||||
}
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
parentReal, _, err := idxTo.GetRealPath(fullDestParent)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %v", err)
|
||||
}
|
||||
destRealPath := filepath.Join(destParentReal, filepath.Base(req.Destination))
|
||||
if err = os.MkdirAll(destParentReal, fileutils.PermDir); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
|
||||
if err = os.MkdirAll(parentReal, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
|
||||
}
|
||||
destRel := strings.TrimLeft(filepath.ToSlash(req.Destination), "/")
|
||||
fileName := path.Base(destRel)
|
||||
if fileName == "." || fileName == "/" {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination file name")
|
||||
}
|
||||
destFileReal := filepath.Join(parentReal, fileName)
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(req.Format))
|
||||
if format == "" {
|
||||
ext := strings.ToLower(filepath.Ext(destRealPath))
|
||||
if ext == ".gz" && len(destRealPath) > 3 && strings.HasSuffix(strings.ToLower(destRealPath), ".tar.gz") {
|
||||
destLower := strings.ToLower(req.Destination)
|
||||
if strings.HasSuffix(destLower, ".tar.gz") {
|
||||
format = "tar.gz"
|
||||
} else if ext == ".zip" {
|
||||
} else if strings.HasSuffix(destLower, ".zip") || filepath.Ext(req.Destination) == ".zip" {
|
||||
format = "zip"
|
||||
} else {
|
||||
format = "zip"
|
||||
@@ -172,7 +316,7 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
}
|
||||
|
||||
var createErr error
|
||||
file, err := os.OpenFile(destRealPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
file, err := os.OpenFile(destFileReal, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.EffectiveFilePerm())
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -299,22 +443,19 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestPath, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
// Destination may not exist yet; resolve parent dir then build destination path.
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
}
|
||||
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
|
||||
}
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
destReal, _, err := idxTo.GetRealPath(fullDestPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
|
||||
}
|
||||
destReal := filepath.Join(destParentReal, filepath.Base(req.Destination))
|
||||
if err = os.MkdirAll(destReal, fileutils.PermDir); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
|
||||
destInfo, err := os.Stat(destReal)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory does not exist: %s", req.Destination)
|
||||
}
|
||||
return http.StatusInternalServerError, fmt.Errorf("destination: %v", err)
|
||||
}
|
||||
if !destInfo.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination must be a directory: %s", req.Destination)
|
||||
}
|
||||
|
||||
info, err := os.Stat(archiveReal)
|
||||
@@ -563,14 +704,20 @@ func createTarGzWithLevel(d *requestContext, source string, w io.Writer, level i
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildAndStreamArchive resolves paths, creates a zip or tar.gz archive, and streams it to w.
|
||||
// It respects access rules. Used only by the raw handler for multi-file/directory download.
|
||||
// BuildAndStreamArchive builds a zip or tar.gz for multi-file/directory download.
|
||||
// Plain GET without Range streams the archive straight to the response (no temp file), matching
|
||||
// clients with chunked downloads disabled. HEAD or Range builds a temp file under cacheDir downloads,
|
||||
// returns X-Archive-Token on the first response, and serves via ServeContent for Range/resume;
|
||||
// follow-up GETs use ?archiveToken=.... Idle chunked sessions delete the spool file after
|
||||
// archiveMultiRequestIdle without another request.
|
||||
//
|
||||
// server.maxArchiveSizeGB is enforced only for the HEAD/Range spool path.
|
||||
func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
realPath, isDir, err := idx.GetRealPath(fileList[0])
|
||||
realPath, _, err := idx.GetRealPath(fileList[0])
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to get real path for %s: %v", fileList[0], err)
|
||||
}
|
||||
@@ -586,35 +733,142 @@ func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
return http.StatusInternalServerError, errors.New("format not implemented")
|
||||
}
|
||||
|
||||
baseDirName := filepath.Base(filepath.Dir(realPath))
|
||||
if baseDirName == "" || baseDirName == "/" {
|
||||
baseDirName = "download"
|
||||
}
|
||||
if len(fileList) == 1 && isDir {
|
||||
baseDirName = filepath.Base(realPath)
|
||||
}
|
||||
originalFileName := baseDirName + extension
|
||||
originalFileName := archiveAttachmentStem(fileList, realPath) + extension
|
||||
|
||||
setContentDisposition(w, r, originalFileName)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
|
||||
var writer io.Writer = w
|
||||
if d.share != nil && d.share.MaxBandwidth > 0 {
|
||||
limit := rate.Limit(d.share.MaxBandwidth * 1024)
|
||||
burst := d.share.MaxBandwidth * 1024
|
||||
writer = newThrottledWriter(w, limit, burst, r.Context())
|
||||
token := r.URL.Query().Get("archiveToken")
|
||||
if token != "" {
|
||||
tmpPath, ok := archiveSpoolPathCache.Get(token)
|
||||
if !ok {
|
||||
return http.StatusGone, fmt.Errorf("invalid or expired archiveToken")
|
||||
}
|
||||
archiveSpoolPathCache.SetWithExp(token, tmpPath, archiveMultiRequestIdle)
|
||||
rescheduleArchiveSpoolIdleCleanup(token, tmpPath)
|
||||
if _, err = os.Stat(tmpPath); err != nil {
|
||||
archiveSpoolPathCache.Delete(token)
|
||||
return http.StatusGone, fmt.Errorf("archive no longer available")
|
||||
}
|
||||
var fd *os.File
|
||||
fd, err = os.Open(tmpPath)
|
||||
if err != nil {
|
||||
return http.StatusGone, err
|
||||
}
|
||||
var fi os.FileInfo
|
||||
fi, err = fd.Stat()
|
||||
if err != nil {
|
||||
_ = fd.Close()
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
code, srvErr := serveArchiveWithServeContent(w, r, d, fd, fi, originalFileName)
|
||||
if closeErr := fd.Close(); closeErr != nil && srvErr == nil {
|
||||
srvErr = closeErr
|
||||
}
|
||||
if srvErr == nil && archiveGetDeliversThroughEOF(r, fi.Size()) {
|
||||
removeSpooledArchiveNow(token, tmpPath)
|
||||
}
|
||||
return code, srvErr
|
||||
}
|
||||
|
||||
// HEAD or Range: same condition as chunked archive probing / retained spool (X-Archive-Token).
|
||||
needMultiRequestSession := r.Method == http.MethodHead || r.Header.Get("Range") != ""
|
||||
|
||||
if needMultiRequestSession && config.Server.MaxArchiveSizeGB > 0 {
|
||||
var estimatedSize int64
|
||||
estimatedSize, err = computeArchiveSize(source, fileList, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to compute archive size: %v", err)
|
||||
}
|
||||
maxSizeBytes := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
|
||||
if estimatedSize > maxSizeBytes {
|
||||
return http.StatusRequestEntityTooLarge, fmt.Errorf("archive size would exceed the maximum allowed size (maxArchiveSize: %d GB)", config.Server.MaxArchiveSizeGB)
|
||||
}
|
||||
}
|
||||
|
||||
// Direct streaming: no temp file (typical browser download when chunked downloads are disabled).
|
||||
if !needMultiRequestSession {
|
||||
if r.Method != http.MethodGet {
|
||||
return http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")
|
||||
}
|
||||
setContentDisposition(w, r, originalFileName)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
writer := io.Writer(w)
|
||||
if d.share != nil && d.share.MaxBandwidth > 0 {
|
||||
limit := rate.Limit(d.share.MaxBandwidth * 1024)
|
||||
burst := d.share.MaxBandwidth * 1024
|
||||
writer = newThrottledWriter(w, limit, burst, r.Context())
|
||||
}
|
||||
if extension == ".zip" {
|
||||
err = createZip(d, source, writer, fileList...)
|
||||
} else {
|
||||
err = createTarGz(d, source, writer, fileList...)
|
||||
}
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
dlDir := settings.DownloadCacheDir()
|
||||
tmpF, err := os.CreateTemp(dlDir, "dl-archive-*"+extension)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("create temp archive: %w", err)
|
||||
}
|
||||
tmpPath := tmpF.Name()
|
||||
|
||||
if extension == ".zip" {
|
||||
err = createZip(d, source, writer, fileList...)
|
||||
err = createZip(d, source, tmpF, fileList...)
|
||||
} else {
|
||||
err = createTarGz(d, source, writer, fileList...)
|
||||
err = createTarGz(d, source, tmpF, fileList...)
|
||||
}
|
||||
if err != nil {
|
||||
_ = tmpF.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
if _, err = tmpF.Seek(0, 0); err != nil {
|
||||
_ = tmpF.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return http.StatusInternalServerError, fmt.Errorf("seek temp archive: %w", err)
|
||||
}
|
||||
fi, err := tmpF.Stat()
|
||||
if err != nil {
|
||||
_ = tmpF.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
sizeInMB := fi.Size() / 1024 / 1024
|
||||
if sizeInMB > 500 {
|
||||
logger.Debugf("User %v is downloading large (%d MB) archive: %v", d.user.Username, sizeInMB, originalFileName)
|
||||
}
|
||||
|
||||
// needMultiRequestSession: HEAD or Range — always spool and use ServeContent (+ token for follow-ups).
|
||||
if err = tmpF.Close(); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
newTok, err := randomArchiveToken()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
archiveSpoolPathCache.SetWithExp(newTok, tmpPath, archiveMultiRequestIdle)
|
||||
rescheduleArchiveSpoolIdleCleanup(newTok, tmpPath)
|
||||
w.Header().Set("X-Archive-Token", newTok)
|
||||
|
||||
fd, err := os.Open(tmpPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
defer fd.Close()
|
||||
fi2, err := fd.Stat()
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return serveArchiveWithServeContent(w, r, d, fd, fi2, originalFileName)
|
||||
}
|
||||
|
||||
// archiveCreateRequest is the body for POST /resources/archive (server-side create).
|
||||
@@ -649,13 +903,53 @@ type unarchiveRequest struct {
|
||||
DeleteAfter bool `json:"deleteAfter"`
|
||||
}
|
||||
|
||||
// safeExtractPath ensures name does not escape destDir (no ".." or absolute).
|
||||
func safeExtractPath(destDir, name string) (string, error) {
|
||||
clean := filepath.Clean(name)
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
// normalizeArchiveEntryName turns a raw zip/tar name into a safe relative path (slash-separated).
|
||||
// Many Windows zips use backslashes; some start with a leading backslash, which would make
|
||||
// filepath.Join drop destDir and write under the drive root. Backslashes are not path separators
|
||||
// on Unix, so we always normalize '\' -> '/' and trim leading '/' before path.Clean.
|
||||
func normalizeArchiveEntryName(name string) (string, error) {
|
||||
s := strings.TrimSpace(name)
|
||||
if s == "" {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
abs := filepath.Join(destDir, clean)
|
||||
s = strings.ReplaceAll(s, "\\", "/")
|
||||
if strings.HasPrefix(s, "//") {
|
||||
return "", errors.New("UNC or invalid path")
|
||||
}
|
||||
s = strings.TrimLeft(s, "/")
|
||||
// Reject e.g. "C:/path" in archive
|
||||
if len(s) >= 3 && s[1] == ':' && s[2] == '/' {
|
||||
if 'A' <= s[0] && s[0] <= 'Z' || 'a' <= s[0] && s[0] <= 'z' {
|
||||
return "", errors.New("absolute path in archive")
|
||||
}
|
||||
}
|
||||
if s == ".." {
|
||||
return "", errors.New("path traversal")
|
||||
}
|
||||
if strings.HasPrefix(s, "../") {
|
||||
return "", errors.New("path traversal")
|
||||
}
|
||||
cleaned := path.Clean(s)
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return "", errors.New("empty path after clean")
|
||||
}
|
||||
if cleaned == ".." {
|
||||
return "", errors.New("path traversal after clean")
|
||||
}
|
||||
if strings.HasPrefix(cleaned, "../") {
|
||||
return "", errors.New("path traversal after clean")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// safeExtractPath ensures name does not escape destDir (no ".." or drive-root / UNC tricks).
|
||||
func safeExtractPath(destDir, name string) (string, error) {
|
||||
relSlash, err := normalizeArchiveEntryName(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid entry path: %q: %w", name, err)
|
||||
}
|
||||
// Convert to platform separators so filepath.Join and os.Mkdir work correctly
|
||||
abs := filepath.Join(destDir, filepath.FromSlash(relSlash))
|
||||
destAbs, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -664,8 +958,13 @@ func safeExtractPath(destDir, name string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(entryAbs, destAbs) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
// Case-sensitive/prefix issues on Windows: use Rel instead of HasPrefix on absolute paths
|
||||
rel, err := filepath.Rel(destAbs, entryAbs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid entry path: %q: %w", name, err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid entry path: %q", name)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
@@ -730,10 +1029,10 @@ func symlinkTargetStaysUnderDest(destRoot, destPath, linkname string) error {
|
||||
func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) error {
|
||||
perm := mode.Perm()
|
||||
if perm == 0 {
|
||||
perm = fileutils.PermDir
|
||||
perm = fileutils.EffectiveDirPerm()
|
||||
}
|
||||
if err := os.MkdirAll(destPath, perm); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("mkdir %q: %w", destPath, err)
|
||||
}
|
||||
return applyArchivedTimesAndPerm(destPath, mode, modTime)
|
||||
}
|
||||
@@ -741,12 +1040,13 @@ func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) er
|
||||
// extractArchivedRegularFile writes one regular file from src (closed by this function) and reapplies mode and mtime.
|
||||
func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.Time, src io.ReadCloser) error {
|
||||
defer func() { _ = src.Close() }()
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
parent := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(parent, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return fmt.Errorf("mkdir %q: %w", parent, err)
|
||||
}
|
||||
perm := mode.Perm()
|
||||
if perm == 0 {
|
||||
perm = fileutils.PermFile
|
||||
perm = fileutils.EffectiveFilePerm()
|
||||
}
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
@@ -766,8 +1066,9 @@ func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.
|
||||
// extractArchivedSymlink creates a symlink after validating the target stays under destRoot.
|
||||
func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time) error {
|
||||
target = strings.TrimSpace(target)
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
symParent := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(symParent, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return fmt.Errorf("mkdir %q: %w", symParent, err)
|
||||
}
|
||||
if err := symlinkTargetStaysUnderDest(destRoot, destPath, target); err != nil {
|
||||
return err
|
||||
@@ -788,7 +1089,39 @@ func extractZip(archivePath, destDir string) error {
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Central directory order is arbitrary. Some zips (e.g. Windows driver packs) use names that
|
||||
// are similar length at different tree depths, so byte length is a poor order key. Use the
|
||||
// number of path segments in the *normalized* entry name: deeper trees first, then
|
||||
// lexicographic, so parent dirs exist before shorter entries that might be mis-tagged.
|
||||
type orderedName struct {
|
||||
f *zip.File
|
||||
rel string
|
||||
depth int
|
||||
}
|
||||
ordered := make([]orderedName, 0, len(r.File))
|
||||
for _, f := range r.File {
|
||||
rel, nerr := normalizeArchiveEntryName(f.Name)
|
||||
if nerr != nil {
|
||||
rel = strings.TrimLeft(strings.ReplaceAll(strings.TrimSpace(f.Name), "\\", "/"), "/")
|
||||
}
|
||||
depth := 0
|
||||
if rel != "" {
|
||||
depth = strings.Count(rel, "/") + 1
|
||||
}
|
||||
ordered = append(ordered, orderedName{f: f, rel: rel, depth: depth})
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
if ordered[i].depth != ordered[j].depth {
|
||||
return ordered[i].depth > ordered[j].depth
|
||||
}
|
||||
if ordered[i].rel != ordered[j].rel {
|
||||
return ordered[i].rel < ordered[j].rel
|
||||
}
|
||||
return ordered[i].f.Name < ordered[j].f.Name
|
||||
})
|
||||
|
||||
for _, o := range ordered {
|
||||
f := o.f
|
||||
var destPath string
|
||||
destPath, err = safeExtractPath(destDir, f.Name)
|
||||
if err != nil {
|
||||
@@ -797,8 +1130,8 @@ func extractZip(archivePath, destDir string) error {
|
||||
|
||||
fi := f.FileInfo()
|
||||
mode := fi.Mode()
|
||||
|
||||
if mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") {
|
||||
isDirEntry := mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") || strings.HasSuffix(f.Name, "\\")
|
||||
if isDirEntry {
|
||||
if err = extractArchivedDir(destPath, mode, f.Modified); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
-10
@@ -132,7 +132,7 @@ func setupJwtUser(r *http.Request, data *requestContext, username string, claims
|
||||
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)
|
||||
@@ -143,12 +143,12 @@ func setupJwtUser(r *http.Request, data *requestContext, username string, claims
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
@@ -158,12 +158,12 @@ func setupJwtUser(r *http.Request, data *requestContext, username string, claims
|
||||
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)
|
||||
@@ -183,7 +183,7 @@ func setupJwtUser(r *http.Request, data *requestContext, username string, claims
|
||||
return nil, fmt.Errorf("user is not in allowed groups")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update admin status if needed
|
||||
shouldBeAdmin := false
|
||||
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
|
||||
@@ -198,7 +198,7 @@ func setupJwtUser(r *http.Request, data *requestContext, username string, claims
|
||||
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")
|
||||
@@ -206,7 +206,7 @@ func setupJwtUser(r *http.Request, data *requestContext, username string, claims
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return data.user, nil
|
||||
}
|
||||
|
||||
@@ -221,7 +221,9 @@ func setupJwtUser(r *http.Request, data *requestContext, username string, claims
|
||||
// @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) {
|
||||
@@ -231,6 +233,9 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
if passwordUser && enforcedOtp && missingOtp {
|
||||
return http.StatusForbidden, errors.ErrNoTotpConfigured
|
||||
}
|
||||
if d.user.HasPasskeyMFA() && d.user.TOTPSecret == "" {
|
||||
return http.StatusForbidden, errors.ErrPasskeyMFARequired
|
||||
}
|
||||
return printToken(w, r, d.user)
|
||||
}
|
||||
|
||||
@@ -317,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")
|
||||
}
|
||||
|
||||
@@ -337,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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+16
-17
@@ -18,19 +18,14 @@ import (
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// throttledWriter is a wrapper around an io.Writer that throttles the writing speed.
|
||||
type throttledWriter struct {
|
||||
w io.Writer
|
||||
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{
|
||||
@@ -58,7 +53,13 @@ func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
return r.rs.Seek(offset, whence)
|
||||
}
|
||||
|
||||
// newThrottledWriter creates a new throttledWriter.
|
||||
// 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,
|
||||
@@ -67,13 +68,11 @@ func newThrottledWriter(w io.Writer, limit rate.Limit, burst int, ctx context.Co
|
||||
}
|
||||
}
|
||||
|
||||
func (w *throttledWriter) Write(p []byte) (n int, err error) {
|
||||
n, err = w.w.Write(p)
|
||||
func (tw *throttledWriter) Write(p []byte) (n int, err error) {
|
||||
n, err = tw.w.Write(p)
|
||||
if n > 0 {
|
||||
if waitErr := w.limiter.WaitN(w.ctx, n); waitErr != nil {
|
||||
if err != nil {
|
||||
// The original error (like io.EOF) is potentially more important
|
||||
// than the context error.
|
||||
if waitErr := tw.limiter.WaitN(tw.ctx, n); waitErr != nil {
|
||||
if err == nil {
|
||||
err = waitErr
|
||||
}
|
||||
}
|
||||
@@ -99,6 +98,8 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
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
|
||||
@@ -274,12 +275,10 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
fmt.Sprintf("OnlyOffice Server downloading file: %s", firstFilePath))
|
||||
}
|
||||
|
||||
// Set headers
|
||||
// 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")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
|
||||
// serve content allows for range requests.
|
||||
// video scrubbing, etc.
|
||||
// Note: http.ServeContent will respect our already-set Content-Disposition header
|
||||
var reader io.ReadSeeker = fd
|
||||
|
||||
@@ -219,6 +219,7 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
|
||||
}
|
||||
if isText {
|
||||
response.IsText = true
|
||||
// Read the last N lines for text files only
|
||||
content, err := readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
|
||||
+57
-31
@@ -10,6 +10,7 @@ import (
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "net/http/pprof"
|
||||
@@ -80,30 +81,40 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
publicRoutes := http.NewServeMux()
|
||||
publicApi := http.NewServeMux()
|
||||
|
||||
// health routes
|
||||
api.HandleFunc("GET /health", healthHandler)
|
||||
publicApi.HandleFunc("GET /health", healthHandler)
|
||||
|
||||
// ========================================
|
||||
// User Routes - /api/users/ (with public routes)
|
||||
// ========================================
|
||||
api.HandleFunc("GET /users", withUser(userGetHandler))
|
||||
api.HandleFunc("POST /users", withSelfOrAdmin(usersPostHandler))
|
||||
api.HandleFunc("PUT /users", withUser(userPutHandler))
|
||||
api.HandleFunc("PATCH /users/pinnedItems", withUser(userPatchPinnedItemsHandler))
|
||||
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
|
||||
publicApi.HandleFunc("GET /users", withUser(userGetHandler))
|
||||
|
||||
// ========================================
|
||||
// Auth Routes - /api/auth/
|
||||
// ========================================
|
||||
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
|
||||
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
|
||||
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
|
||||
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(generateOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(verifyOTPHandler))
|
||||
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
|
||||
api.HandleFunc("POST /auth/token", withUser(createApiTokenHandler))
|
||||
api.HandleFunc("DELETE /auth/token", withUser(deleteApiTokenHandler))
|
||||
api.HandleFunc("GET /auth/token/list", withUser(listApiTokensHandler))
|
||||
api.HandleFunc("GET /auth/token", withUser(getApiTokenHandler))
|
||||
api.HandleFunc("GET /auth/oidc/callback", wrapHandler(oidcCallbackHandler))
|
||||
api.HandleFunc("GET /auth/oidc/login", wrapHandler(oidcLoginHandler))
|
||||
api.HandleFunc("POST /auth/login", withRateLimit(AuthRateLimitCredentialLockout, loginHelper(loginHandler)))
|
||||
api.HandleFunc("POST /auth/logout", withOrWithoutUser(withRateLimitChain(AuthRateLimitModerate, logoutHandler)))
|
||||
api.HandleFunc("POST /auth/signup", withoutUser(withRateLimitChain(AuthRateLimitModerate, signupHandler)))
|
||||
api.HandleFunc("POST /auth/otp/generate", withOrWithoutUser(withRateLimitChain(AuthRateLimitModerate, generateOTPHandler)))
|
||||
api.HandleFunc("POST /auth/otp/verify", withOrWithoutUser(withRateLimitChain(AuthRateLimitCredentialLockout, verifyOTPHandler)))
|
||||
api.HandleFunc("POST /auth/renew", withUser(withRateLimitChain(AuthRateLimitAuthenticated, renewHandler)))
|
||||
api.HandleFunc("POST /auth/token", withUser(withRateLimitChain(AuthRateLimitAuthenticated, createApiTokenHandler)))
|
||||
api.HandleFunc("DELETE /auth/token", withUser(withRateLimitChain(AuthRateLimitAuthenticated, deleteApiTokenHandler)))
|
||||
api.HandleFunc("GET /auth/token/list", withUser(withRateLimitChain(AuthRateLimitAuthenticated, listApiTokensHandler)))
|
||||
api.HandleFunc("GET /auth/token", withUser(withRateLimitChain(AuthRateLimitAuthenticated, getApiTokenHandler)))
|
||||
api.HandleFunc("GET /auth/oidc/callback", withRateLimit(AuthRateLimitOIDC, oidcCallbackHandler))
|
||||
api.HandleFunc("GET /auth/oidc/login", withRateLimit(AuthRateLimitOIDC, oidcLoginHandler))
|
||||
api.HandleFunc("POST /auth/webauthn/begin-login", withoutUser(withRateLimitChain(AuthRateLimitCredential, beginPasskeyLoginHandler)))
|
||||
api.HandleFunc("POST /auth/webauthn/finish-login", withoutUser(withRateLimitChain(AuthRateLimitCredential, finishPasskeyLoginHandler)))
|
||||
api.HandleFunc("POST /auth/webauthn/begin-register", withUser(withRateLimitChain(AuthRateLimitAuthenticated, beginPasskeyRegistrationHandler)))
|
||||
api.HandleFunc("POST /auth/webauthn/finish-register", withUser(withRateLimitChain(AuthRateLimitAuthenticated, finishPasskeyRegistrationHandler)))
|
||||
api.HandleFunc("DELETE /auth/webauthn/{id}", withUser(withRateLimitChain(AuthRateLimitAuthenticated, deletePasskeyCredentialHandler)))
|
||||
|
||||
// ========================================
|
||||
// Resources Routes - /api/resources/ (with public routes)
|
||||
@@ -118,7 +129,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("POST /resources/archive", withUser(archiveCreateHandler))
|
||||
api.HandleFunc("POST /resources/unarchive", withUser(unarchiveHandler))
|
||||
api.HandleFunc("GET /resources/download", withUser(downloadHandler))
|
||||
api.HandleFunc("GET /resources/preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
|
||||
api.HandleFunc("GET /resources/preview", withTimeout(30*time.Second, withUserHelper(previewHandler)))
|
||||
api.HandleFunc("POST /resources/pause", withUser(resourcePauseHandler))
|
||||
publicApi.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
|
||||
publicApi.HandleFunc("GET /resources/items", withHashFile(publicItemsGetHandler))
|
||||
@@ -128,7 +139,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
publicApi.HandleFunc("DELETE /resources/bulk", withHashFile(publicBulkDeleteHandler))
|
||||
publicApi.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
|
||||
publicApi.HandleFunc("GET /resources/download", withHashFile(publicDownloadHandler))
|
||||
publicApi.HandleFunc("GET /resources/preview", withHashFile(publicPreviewHandler))
|
||||
publicApi.HandleFunc("GET /resources/preview", withTimeout(30*time.Second, withHashFileHelper(publicPreviewHandler)))
|
||||
publicApi.HandleFunc("POST /resources/pause", withHashFile(publicPauseHandler))
|
||||
// Legacy routes (backwards compatibility)
|
||||
api.HandleFunc("GET /raw", withUser(downloadHandler))
|
||||
@@ -150,11 +161,12 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// ========================================
|
||||
api.HandleFunc("GET /share/list", withPermShare(shareListHandler))
|
||||
api.HandleFunc("GET /share/direct", withPermShare(shareDirectDownloadHandler))
|
||||
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
|
||||
api.HandleFunc("GET /share", withUser(shareGetHandler))
|
||||
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
|
||||
api.HandleFunc("PATCH /share", withPermShare(sharePatchHandler))
|
||||
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
|
||||
publicApi.HandleFunc("GET /share/info", withOrWithoutUser(shareInfoHandler))
|
||||
publicApi.HandleFunc("PATCH /share/pinnedItems", withPermShare(sharePatchPinnedItemsHandler))
|
||||
publicApi.HandleFunc("GET /share/image", withHashFile(getShareImage))
|
||||
|
||||
// ========================================
|
||||
@@ -177,7 +189,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// ========================================
|
||||
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
|
||||
api.HandleFunc("GET /media/metadata", withUser(metadataHandler))
|
||||
api.HandleFunc("GET /media/lyrics", withUser(lyricsHandler))
|
||||
publicApi.HandleFunc("GET /media/metadata", withHashFile(publicMetadataHandler))
|
||||
publicApi.HandleFunc("GET /media/lyrics", withHashFile(publicLyricsHandler))
|
||||
|
||||
// ========================================
|
||||
// OnlyOffice Routes - /api/office/ (with public routes)
|
||||
@@ -192,7 +206,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// ========================================
|
||||
// Misc Routes
|
||||
// ========================================
|
||||
api.HandleFunc("GET /health", healthHandler)
|
||||
api.HandleFunc("GET /events", withUser(sseHandler))
|
||||
if settings.Env.IsDevMode {
|
||||
api.HandleFunc("GET /inspectIndex", inspectIndex)
|
||||
@@ -243,16 +256,28 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
})
|
||||
}
|
||||
|
||||
var scheme string
|
||||
port := ""
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%v:%v", config.Server.ListenAddress, config.Server.Port),
|
||||
Handler: muxWithMiddleware(router),
|
||||
}
|
||||
listenAddress := config.Server.ListenAddress
|
||||
go func() {
|
||||
// Determine whether to use HTTPS (TLS) or HTTP
|
||||
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
|
||||
var listener net.Listener
|
||||
|
||||
if config.Server.Socket != "" {
|
||||
if err := os.Remove(config.Server.Socket); err != nil && !os.IsNotExist(err) {
|
||||
logger.Fatalf("Could not remove existing socket: %v", err)
|
||||
}
|
||||
var err error
|
||||
listener, err = net.Listen("unix", config.Server.Socket)
|
||||
if err != nil {
|
||||
logger.Fatalf("Could not listen on unix socket: %v", err)
|
||||
}
|
||||
logger.Infof("Running at : unix://%s%s", config.Server.Socket, config.Server.BaseURL)
|
||||
} else if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
|
||||
var scheme string
|
||||
port := ""
|
||||
// Determine whether to use HTTPS (TLS) or HTTP
|
||||
// Load the TLS certificate and key
|
||||
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
|
||||
if err != nil {
|
||||
@@ -276,8 +301,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
logger.Infof("Running at : %s", fullURL)
|
||||
|
||||
// Attempt to get listener from socket activation with TLS configuration
|
||||
var listener net.Listener
|
||||
|
||||
socketActivationListeners, err := activation.TLSListeners(tlsConfig)
|
||||
if err == nil && len(socketActivationListeners) > 0 {
|
||||
listener = socketActivationListeners[0]
|
||||
@@ -296,10 +319,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
}
|
||||
}
|
||||
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatalf("Server error: %v", err)
|
||||
}
|
||||
} else {
|
||||
var scheme string
|
||||
port := ""
|
||||
// Set HTTP scheme and the default port for HTTP
|
||||
scheme = "http"
|
||||
if config.Server.Port != 80 {
|
||||
@@ -310,8 +332,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
|
||||
logger.Infof("Running at : %s", fullURL)
|
||||
|
||||
var listener net.Listener
|
||||
|
||||
// Attempt to get the listener from socket activation
|
||||
socketActivationListeners, err := activation.Listeners()
|
||||
if err == nil && len(socketActivationListeners) > 0 {
|
||||
@@ -337,10 +357,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
}
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -379,6 +399,12 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
logger.Info("HTTP server shut down gracefully.")
|
||||
}
|
||||
|
||||
if config.Server.Socket != "" {
|
||||
if err := os.Remove(config.Server.Socket); err != nil && !os.IsNotExist(err) {
|
||||
logger.Debugf("Could not remove unix socket: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Signal that shutdown is complete
|
||||
close(shutdownComplete)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func AuthenticateLDAPUser(username, password string) (*users.User, error) {
|
||||
}
|
||||
|
||||
// Use the configured UserIdentifier if available, otherwise use the login username
|
||||
ldapCfg := settings.Config.Auth.Methods.LdapAuth
|
||||
ldapCfg := config.Auth.Methods.LdapAuth
|
||||
mappedUsername := username
|
||||
if ldapCfg.UserIdentifier != "" {
|
||||
if val, ok := userAttributes[ldapCfg.UserIdentifier]; ok && val != "" {
|
||||
@@ -42,7 +42,7 @@ func AuthenticateLDAPUser(username, password string) (*users.User, error) {
|
||||
}
|
||||
|
||||
func authenticateLDAP(username, password string) ([]string, map[string]string, error) {
|
||||
c := settings.Config.Auth.Methods.LdapAuth
|
||||
c := config.Auth.Methods.LdapAuth
|
||||
logger.Debugf("ldap: connecting to %s", c.Server)
|
||||
|
||||
var opts []ldap.DialOpt
|
||||
@@ -229,7 +229,7 @@ func getOrCreateLdapUser(username string, groups []string) (*users.User, error)
|
||||
}
|
||||
// Auto-create user on first LDAP authentication
|
||||
if ldapCfg.AdminGroup == "" {
|
||||
isAdmin = config.UserDefaults.Permissions.Admin
|
||||
isAdmin = config.UserDefaults.Account.Permissions.Admin
|
||||
}
|
||||
user = &users.User{
|
||||
Username: username,
|
||||
|
||||
+127
-40
@@ -2,17 +2,16 @@ package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
)
|
||||
|
||||
// subtitlesHandler handles subtitle requests for both external files and embedded streams
|
||||
@@ -37,58 +36,49 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
name := r.URL.Query().Get("name")
|
||||
embedded := r.URL.Query().Get("embedded") == "true"
|
||||
|
||||
if path == "" || source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and source are required")
|
||||
}
|
||||
if name == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
|
||||
}
|
||||
|
||||
userscope, err := d.user.GetScopeForSourceName(source)
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: true,
|
||||
Content: false,
|
||||
Metadata: true,
|
||||
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
HideFileExt: d.user.HideFileExt,
|
||||
SkipExtendedAttrs: false,
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if !strings.HasPrefix(fileInfo.Type, "video") {
|
||||
return http.StatusNotFound, fmt.Errorf("file is not a video")
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
track := findSubtitleTrack(fileInfo.Subtitles, name, embedded)
|
||||
if track == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("subtitle track '%s' not found", name)
|
||||
}
|
||||
|
||||
realPath, _, err := idx.GetRealPath(userscope, path)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
parentDir := filepath.Dir(realPath)
|
||||
var content string
|
||||
|
||||
if !embedded {
|
||||
content, err = utils.GetSubtitleSidecarContent(filepath.Join(parentDir, name))
|
||||
subtitlePath := filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
|
||||
content, err = utils.GetSubtitleSidecarContent(subtitlePath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
|
||||
}
|
||||
} else {
|
||||
// For embedded subtitles, we need to find the stream index by name
|
||||
// Get file modification time for caching
|
||||
fileInfo, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to stat file: %v", err)
|
||||
}
|
||||
|
||||
// Detect embedded subtitles
|
||||
embeddedSubs := ffmpeg.DetectEmbeddedSubtitles(realPath, fileInfo.ModTime())
|
||||
|
||||
// Find the subtitle track by name
|
||||
var streamIndex *int
|
||||
for _, sub := range embeddedSubs {
|
||||
if sub.Name == name {
|
||||
streamIndex = sub.Index
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if streamIndex == nil {
|
||||
if track.Index == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("embedded subtitle track '%s' not found", name)
|
||||
}
|
||||
|
||||
content, err = ffmpeg.ExtractSubtitleContent(realPath, *streamIndex)
|
||||
content, err = ffmpeg.ExtractSubtitleContent(fileInfo.RealPath, *track.Index)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to extract embedded subtitle: %v", err)
|
||||
}
|
||||
@@ -102,6 +92,16 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
func findSubtitleTrack(subtitles []utils.SubtitleTrack, name string, embedded bool) *utils.SubtitleTrack {
|
||||
for i := range subtitles {
|
||||
sub := &subtitles[i]
|
||||
if sub.Name == name && sub.Embedded == embedded {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// metadataHandler returns the same directory resource shape as GET /api/resources with metadata enabled,
|
||||
// for client-side patching after a fast listing load.
|
||||
// @Summary Directory with media metadata
|
||||
@@ -128,8 +128,9 @@ func metadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
Content: false,
|
||||
Metadata: true,
|
||||
AlbumArt: albumArt,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
HideFileExt: d.user.HideFileExt,
|
||||
SkipExtendedAttrs: false,
|
||||
ShowSharedAttr: true,
|
||||
}, store.Access, d.user, store.Share)
|
||||
@@ -165,8 +166,9 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
Content: false,
|
||||
Metadata: true,
|
||||
AlbumArt: albumArt,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
|
||||
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.share.ShowHidden,
|
||||
HideFileExt: d.user.HideFileExt,
|
||||
FollowSymlinks: false,
|
||||
}, store.Access, d.shareUser, store.Share)
|
||||
if err != nil {
|
||||
@@ -175,3 +177,88 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
fileInfo.Path = utils.AddTrailingSlashIfNotExists(path)
|
||||
return renderJSON(w, r, fileInfo)
|
||||
}
|
||||
|
||||
// lyricsHandler returns synced/unsynced lyrics (with or without timestamps) for an audio file (embedded or from .lrc files).
|
||||
// @Summary Get lyrics for an audio file
|
||||
// @Description Returns parsed lyrics with optional timestamps from embedded tags or sidecar .lrc files.
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the directory or file"
|
||||
// @Param source query string true "Source name"
|
||||
// @Success 200 {object} map[string]interface{} "Lyrics array"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Router /api/media/lyrics [get]
|
||||
func lyricsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
if path == "" || source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and source are required")
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: true,
|
||||
Content: false,
|
||||
Metadata: false,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
HideFileExt: d.user.HideFileExt,
|
||||
SkipExtendedAttrs: false,
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if !strings.HasPrefix(fileInfo.Type, "audio") {
|
||||
return http.StatusNotFound, fmt.Errorf("file is not an audio file")
|
||||
}
|
||||
|
||||
lyrics, err := files.ExtractLyrics(fileInfo.RealPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to extract lyrics: %v", err)
|
||||
}
|
||||
return renderJSON(w, r, map[string]any{"lyrics": lyrics})
|
||||
}
|
||||
|
||||
// publicLyricsHandler is the share-link variant of lyricsHandler.
|
||||
// @Summary Get lyrics for an audio file (public share)
|
||||
// @Tags Shares
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash"
|
||||
// @Param path query string false "Path within the share"
|
||||
// @Success 200 {object} map[string]interface{} "Lyrics array"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Router /public/api/media/lyrics [get]
|
||||
func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
sourceCfg, ok := config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("source not found")
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: d.IndexPath,
|
||||
Source: sourceCfg.Name,
|
||||
Expand: true,
|
||||
Content: false,
|
||||
Metadata: false,
|
||||
ShowHidden: d.share.ShowHidden,
|
||||
HideFileExt: d.user.HideFileExt,
|
||||
FollowSymlinks: false,
|
||||
SkipExtendedAttrs: false,
|
||||
}, store.Access, d.shareUser, store.Share)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if !strings.HasPrefix(fileInfo.Type, "audio") {
|
||||
return http.StatusNotFound, fmt.Errorf("file is not an audio file")
|
||||
}
|
||||
|
||||
lyrics, err := files.ExtractLyrics(fileInfo.RealPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, errors.New("failed to extract lyrics")
|
||||
}
|
||||
return renderJSON(w, r, map[string]any{"lyrics": lyrics})
|
||||
}
|
||||
+32
-58
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -31,7 +32,6 @@ type requestContext struct {
|
||||
fileInfo iteminfo.ExtendedFileInfo
|
||||
token string
|
||||
share *share.Link
|
||||
shareValid bool
|
||||
ctx context.Context
|
||||
MaxBandwidth int
|
||||
Data interface{}
|
||||
@@ -53,7 +53,12 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
|
||||
func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
path := r.URL.Query().Get("path")
|
||||
inputPath := r.URL.Query().Get("path")
|
||||
path, err := utils.SanitizeUserPath(inputPath)
|
||||
if err != nil && inputPath != "" {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
path = filepath.ToSlash(path)
|
||||
|
||||
// Get the file link by hash
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
@@ -128,9 +133,12 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
Content: getContent,
|
||||
Metadata: getMetadata,
|
||||
AlbumArt: strings.Contains(r.URL.Path, "/preview"),
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
|
||||
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: link.ShowHidden,
|
||||
HideFileExt: link.HideFileExt,
|
||||
FollowSymlinks: true,
|
||||
ShowPinnedItems: true,
|
||||
ShareHash: hash,
|
||||
}, store.Access, data.shareUser, store.Share)
|
||||
if err != nil {
|
||||
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
|
||||
@@ -222,57 +230,19 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
|
||||
// If authentication fails, the request continues without a user.
|
||||
func withOrWithoutUserHelper(fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
var link *share.Link
|
||||
var isShareRequest bool
|
||||
var shareHash string
|
||||
|
||||
hash := r.URL.Query().Get("hash")
|
||||
if hash != "" {
|
||||
// Get the file link by hash
|
||||
isShareRequest = true
|
||||
shareHash = hash
|
||||
link, _ = store.Share.GetByHash(hash)
|
||||
} else {
|
||||
prefix := config.Server.BaseURL + "public/share/"
|
||||
reconstructed := config.Server.BaseURL + "public" + r.URL.Path
|
||||
if strings.HasPrefix(reconstructed, prefix) {
|
||||
remaining := strings.TrimPrefix(reconstructed, prefix)
|
||||
if remaining != "" {
|
||||
if idx := strings.IndexByte(remaining, '/'); idx >= 0 {
|
||||
remaining = remaining[:idx]
|
||||
}
|
||||
if remaining != "" {
|
||||
isShareRequest = true
|
||||
shareHash = remaining
|
||||
var err error
|
||||
link, err = store.Share.GetByHash(remaining)
|
||||
if err != nil {
|
||||
logger.Debugf("error getting share by hash: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a share request, always create a share context (even if invalid)
|
||||
if isShareRequest {
|
||||
if link != nil {
|
||||
data.share = link
|
||||
data.shareValid = true
|
||||
} else {
|
||||
// Create an empty share with just the hash for invalid shares
|
||||
data.share = &share.Link{Hash: shareHash}
|
||||
data.shareValid = false
|
||||
_, err := store.Share.GetByHash(hash)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("share hash not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Try to authenticate user first
|
||||
status, err := withUserHelper(nil)(w, r, data)
|
||||
if err == nil && status < 400 {
|
||||
if data.share != nil && data.shareValid {
|
||||
if data.user != nil {
|
||||
data.user.CustomTheme = data.share.ShareTheme
|
||||
}
|
||||
if data.share != nil && data.user != nil {
|
||||
data.user.CustomTheme = data.share.ShareTheme
|
||||
}
|
||||
return fn(w, r, data)
|
||||
}
|
||||
@@ -283,7 +253,7 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
|
||||
userFromExpiredToken := extractUserFromExpiredToken(r, data)
|
||||
if userFromExpiredToken != nil {
|
||||
data.user = userFromExpiredToken
|
||||
if data.share != nil && data.shareValid {
|
||||
if data.share != nil {
|
||||
data.user.CustomTheme = data.share.ShareTheme
|
||||
}
|
||||
setUserInResponseWriter(w, data.user)
|
||||
@@ -295,7 +265,7 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
|
||||
settings.ApplyUserDefaults(data.user)
|
||||
// Clear any user data that might have been partially set
|
||||
data.token = ""
|
||||
if data.share != nil && data.shareValid {
|
||||
if data.share != nil {
|
||||
data.user.CustomTheme = data.share.ShareTheme
|
||||
}
|
||||
// Call the handler function without user context
|
||||
@@ -314,7 +284,7 @@ func withoutUserHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
|
||||
// allow user without OTP to pass
|
||||
func userWithoutOTPhelper(fn handleFunc) handleFunc {
|
||||
func LoginHelper(disableOtp bool, fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
|
||||
if config.Auth.Methods.ProxyAuth.Enabled {
|
||||
@@ -344,15 +314,19 @@ func userWithoutOTPhelper(fn handleFunc) handleFunc {
|
||||
logger.Debug("ldap auth failed, calling password auth", err)
|
||||
}
|
||||
if config.Auth.Methods.PasswordAuth.Enabled {
|
||||
// Get the authentication method from the settings
|
||||
auther, err := store.Auth.Get("password")
|
||||
if err != nil {
|
||||
return 401, errors.ErrUnauthorized
|
||||
}
|
||||
// Authenticate the user based on the request
|
||||
// OTP routes: password only (do not mutate stored *JSONAuth).
|
||||
if ja, ok := auther.(*auth.JSONAuth); ok {
|
||||
x := *ja
|
||||
x.DisableOtp = disableOtp
|
||||
auther = &x
|
||||
}
|
||||
user, err := auther.Auth(r, store.Users)
|
||||
if err != nil {
|
||||
logger.Debug("password auth failed, calling handler", err)
|
||||
logger.Debug("password auth failed, calling handler:", err)
|
||||
if err == errors.ErrNoTotpProvided {
|
||||
return 403, err
|
||||
}
|
||||
@@ -655,8 +629,8 @@ func withoutUser(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(withoutUserHelper(fn))
|
||||
}
|
||||
|
||||
func userWithoutOTP(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(userWithoutOTPhelper(fn))
|
||||
func loginHelper(fn handleFunc) handleFunc {
|
||||
return LoginHelper(false, fn)
|
||||
}
|
||||
|
||||
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
|
||||
@@ -751,7 +725,7 @@ func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
|
||||
func getRemoteIP(r *http.Request) string {
|
||||
// 1. Check X-Forwarded-For
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff != "" {
|
||||
if config.Http.TrustedHeaders["x-forwarded-for"] && xff != "" {
|
||||
// The first IP is the original client
|
||||
ips := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(ips[0])
|
||||
@@ -759,7 +733,7 @@ func getRemoteIP(r *http.Request) string {
|
||||
|
||||
// 2. Check X-Real-IP
|
||||
xri := r.Header.Get("X-Real-IP")
|
||||
if xri != "" {
|
||||
if config.Http.TrustedHeaders["x-real-ip"] && xri != "" {
|
||||
return xri
|
||||
}
|
||||
|
||||
@@ -826,8 +800,8 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
duration := time.Since(start)
|
||||
|
||||
// Use the StatusCode from wrappedWriter, which might have been set to 500 by the recover logic
|
||||
logger.Api(wrappedWriter.StatusCode,
|
||||
// ApiPathExclude is applied per logging sink inside go-logger (logger.ApiPath).
|
||||
logger.ApiPath(wrappedWriter.StatusCode, fullURL,
|
||||
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
|
||||
r.Method,
|
||||
wrappedWriter.StatusCode,
|
||||
|
||||
@@ -159,7 +159,7 @@ func parseOIDCGroupsValue(groupsVal interface{}) []string {
|
||||
// @Success 302 {string} string "Redirect to OIDC provider"
|
||||
// @Router /api/auth/oidc/login [get]
|
||||
func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
oidcCfg := settings.Config.Auth.Methods.OidcAuth
|
||||
oidcCfg := config.Auth.Methods.OidcAuth
|
||||
if !oidcCfg.Enabled {
|
||||
return http.StatusForbidden, fmt.Errorf("oidc authentication is not enabled")
|
||||
}
|
||||
@@ -197,7 +197,7 @@ func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Router /api/auth/oidc/callback [get]
|
||||
func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
ctx := r.Context()
|
||||
oidcCfg := settings.Config.Auth.Methods.OidcAuth
|
||||
oidcCfg := config.Auth.Methods.OidcAuth
|
||||
if oidcCfg.Provider == nil || oidcCfg.Verifier == nil {
|
||||
// Ensure Provider and Verifier are initialized on application startup
|
||||
// This check is good, keep it.
|
||||
@@ -255,8 +255,6 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// --- Attempt to process ID Token ---
|
||||
if ok && rawIDToken != "" {
|
||||
logger.Debugf("ID token found in token response, attempting verification. Raw ID Token: %s", rawIDToken)
|
||||
|
||||
// Verify the ID token
|
||||
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
|
||||
idToken, verify_err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
|
||||
|
||||
+137
-25
@@ -11,12 +11,13 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -28,8 +29,16 @@ const (
|
||||
onlyOfficeStatusDocumentClosedWithNoChanges = 4
|
||||
onlyOfficeStatusForceSaveWhileDocumentStillOpen = 6
|
||||
onlyOfficeStatusForceSaveError = 7
|
||||
|
||||
onlyOfficeDownloadTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// onlyOfficeDownloadClient fetches saved documents from the OnlyOffice document server.
|
||||
// A bounded timeout avoids hanging goroutines when the server is unreachable.
|
||||
var onlyOfficeDownloadClient = &http.Client{
|
||||
Timeout: onlyOfficeDownloadTimeout,
|
||||
}
|
||||
|
||||
type OnlyOfficeCallback struct {
|
||||
Actions []OnlyOfficeAction `json:"actions,omitempty"`
|
||||
ChangesURL string `json:"changesurl,omitempty"`
|
||||
@@ -81,7 +90,7 @@ type OnlyOfficeJWTPayload struct {
|
||||
// @Router /api/office/config [get]
|
||||
// @Security ApiKeyAuth
|
||||
func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if settings.Config.Integrations.OnlyOffice.Url == "" {
|
||||
if config.Integrations.OnlyOffice.Url == "" {
|
||||
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
|
||||
}
|
||||
|
||||
@@ -107,13 +116,13 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
var sourceInfo *settings.Source
|
||||
var ok bool
|
||||
if hash != "" {
|
||||
sourceInfo, ok = settings.Config.Server.SourceMap[d.share.Source]
|
||||
sourceInfo, ok = config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: source not found")
|
||||
return http.StatusInternalServerError, fmt.Errorf("source not found")
|
||||
}
|
||||
} else {
|
||||
sourceInfo, ok = settings.Config.Server.NameToSource[source]
|
||||
sourceInfo, ok = config.Server.NameToSource[source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: source not found")
|
||||
return http.StatusInternalServerError, fmt.Errorf("source not found")
|
||||
@@ -210,7 +219,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
"title": d.fileInfo.Name,
|
||||
"url": downloadURL,
|
||||
"permissions": map[string]interface{}{
|
||||
"edit": utils.Ternary(settings.Config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
|
||||
"edit": utils.Ternary(config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
|
||||
"download": true,
|
||||
"print": true,
|
||||
},
|
||||
@@ -227,14 +236,14 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
"uiTheme": themeMode,
|
||||
},
|
||||
"lang": d.user.Locale,
|
||||
"mode": utils.Ternary(settings.Config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
|
||||
"mode": utils.Ternary(config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
|
||||
},
|
||||
}
|
||||
|
||||
// Sign configuration with JWT if secret is configured
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" {
|
||||
if config.Integrations.OnlyOffice.Secret != "" {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(clientConfig))
|
||||
signature, err := token.SignedString([]byte(settings.Config.Integrations.OnlyOffice.Secret))
|
||||
signature, err := token.SignedString([]byte(config.Integrations.OnlyOffice.Secret))
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice: failed to sign JWT: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to sign configuration")
|
||||
@@ -249,10 +258,10 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token string) string {
|
||||
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
|
||||
var baseURL string
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
if config.Server.InternalUrl != "" {
|
||||
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
|
||||
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
|
||||
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
|
||||
internalURL := strings.TrimSuffix(config.Server.InternalUrl, "/")
|
||||
baseURLPath := strings.TrimPrefix(config.Server.BaseURL, "/")
|
||||
if baseURLPath != "" {
|
||||
baseURL = internalURL + "/" + baseURLPath
|
||||
} else {
|
||||
@@ -276,7 +285,7 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
|
||||
host = r.Host
|
||||
scheme = getScheme(r)
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
|
||||
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, config.Server.BaseURL)
|
||||
}
|
||||
|
||||
escapedPath := url.QueryEscape(path)
|
||||
@@ -293,10 +302,10 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
|
||||
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token string) string {
|
||||
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
|
||||
var baseURL string
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
if config.Server.InternalUrl != "" {
|
||||
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
|
||||
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
|
||||
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
|
||||
internalURL := strings.TrimSuffix(config.Server.InternalUrl, "/")
|
||||
baseURLPath := strings.TrimPrefix(config.Server.BaseURL, "/")
|
||||
if baseURLPath != "" {
|
||||
baseURL = internalURL + "/" + baseURLPath
|
||||
} else {
|
||||
@@ -320,7 +329,7 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
|
||||
host = r.Host
|
||||
scheme = getScheme(r)
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
|
||||
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, config.Server.BaseURL)
|
||||
}
|
||||
|
||||
var callbackURL string
|
||||
@@ -347,6 +356,97 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
|
||||
return callbackURL
|
||||
}
|
||||
|
||||
// resolveOnlyOfficeDownloadURL validates a callback document URL against
|
||||
// integrations.office.url and optionally rewrites the origin to integrations.office.internalUrl.
|
||||
// Returns an empty string when the URL is missing, malformed, or not hosted on the configured
|
||||
// OnlyOffice server (SSRF protection).
|
||||
func resolveOnlyOfficeDownloadURL(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
publicBase := config.Integrations.OnlyOffice.Url
|
||||
if publicBase == "" {
|
||||
logger.Warningf("OnlyOffice callback: integrations.office.url is not configured")
|
||||
return ""
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
logger.Warningf("OnlyOffice callback: could not parse document URL (%q): %v", rawURL, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
publicURL, err := url.Parse(publicBase)
|
||||
if err != nil {
|
||||
logger.Warningf("OnlyOffice callback: could not parse integrations.office.url (%q): %v", publicBase, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if !onlyOfficeURLHostsMatch(parsedURL, publicURL) {
|
||||
logger.Warningf("OnlyOffice callback: rejecting document URL with untrusted host %q (expected %q)",
|
||||
parsedURL.Host, publicURL.Host)
|
||||
return ""
|
||||
}
|
||||
|
||||
internalBase := config.Integrations.OnlyOffice.InternalUrl
|
||||
if internalBase == "" {
|
||||
return rawURL
|
||||
}
|
||||
|
||||
internalURL, err := url.Parse(internalBase)
|
||||
if err != nil {
|
||||
logger.Warningf("OnlyOffice callback: could not parse integrations.office.internalUrl (%q): %v", internalBase, err)
|
||||
return ""
|
||||
}
|
||||
if !isAllowedOnlyOfficeScheme(internalURL.Scheme) || internalURL.Host == "" {
|
||||
logger.Warningf("OnlyOffice callback: invalid integrations.office.internalUrl (%q)", internalBase)
|
||||
return ""
|
||||
}
|
||||
|
||||
rewritten := *parsedURL
|
||||
rewritten.Scheme = internalURL.Scheme
|
||||
rewritten.Host = internalURL.Host
|
||||
result := rewritten.String()
|
||||
if result != rawURL {
|
||||
logger.Debugf("OnlyOffice callback: rewrote URL from %s to %s", rawURL, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isAllowedOnlyOfficeScheme(scheme string) bool {
|
||||
return scheme == "http" || scheme == "https"
|
||||
}
|
||||
|
||||
func onlyOfficeEffectivePort(u *url.URL) string {
|
||||
if port := u.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https":
|
||||
return "443"
|
||||
case "http":
|
||||
return "80"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// onlyOfficeURLHostsMatch reports whether callback and configured URLs refer to the same
|
||||
// OnlyOffice host, comparing hostname and effective port (so office.local matches office.local:80).
|
||||
func onlyOfficeURLHostsMatch(callback, configured *url.URL) bool {
|
||||
if !isAllowedOnlyOfficeScheme(callback.Scheme) || !isAllowedOnlyOfficeScheme(configured.Scheme) {
|
||||
return false
|
||||
}
|
||||
if callback.Hostname() == "" || configured.Hostname() == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(callback.Hostname(), configured.Hostname()) {
|
||||
return false
|
||||
}
|
||||
return onlyOfficeEffectivePort(callback) == onlyOfficeEffectivePort(configured)
|
||||
}
|
||||
|
||||
// processOnlyOfficeCallback handles the common callback processing logic for both GET and POST requests
|
||||
func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *requestContext, data *OnlyOfficeCallback) (int, error) {
|
||||
// Extract clean parameters from query string
|
||||
@@ -382,7 +482,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
//
|
||||
// When the document is fully closed by all editors,
|
||||
// the document key should no longer be re-used.
|
||||
deleteOfficeId(source, path)
|
||||
deleteOfficeId(source, path, user)
|
||||
|
||||
// Send log event for document closure and clean up log context
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
@@ -492,7 +592,12 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
// Download the updated document from OnlyOffice server
|
||||
doc, err := http.Get(data.URL)
|
||||
downloadURL := resolveOnlyOfficeDownloadURL(data.URL)
|
||||
if downloadURL == "" {
|
||||
logger.Errorf("OnlyOffice callback: missing or untrusted document URL in callback payload")
|
||||
return returnOnlyOfficeError(w, r, 500, "missing or untrusted document URL in callback payload")
|
||||
}
|
||||
doc, err := onlyOfficeDownloadClient.Get(downloadURL)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice callback: failed to download updated document: %v", err)
|
||||
return returnOnlyOfficeError(w, r, 500, "failed to download updated document")
|
||||
@@ -650,14 +755,21 @@ func getOnlyOfficeId(realpath string) (string, error) {
|
||||
return "", fmt.Errorf("document key not found")
|
||||
}
|
||||
|
||||
func deleteOfficeId(source, path string) {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
logger.Errorf("deleteOfficeId: failed to find source index for user home dir creation: %s", source)
|
||||
return
|
||||
func deleteOfficeId(source, path string, user *users.User) {
|
||||
fi, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, user, store.Share)
|
||||
key := path
|
||||
if fi != nil && fi.RealPath != "" {
|
||||
key = fi.RealPath
|
||||
}
|
||||
realpath, _, _ := idx.GetRealPath(path)
|
||||
utils.OnlyOfficeCache.Delete(realpath)
|
||||
if err != nil {
|
||||
logger.Errorf("deleteOfficeId: failed to resolve realpath, source=%s, path=%s: %v", source, path, err)
|
||||
}
|
||||
utils.OnlyOfficeCache.Delete(key)
|
||||
}
|
||||
|
||||
// parseOnlyOfficeJWT parses the JWT token from OnlyOffice callback
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestResolveOnlyOfficeDownloadURL(t *testing.T) {
|
||||
orig := config.Integrations.OnlyOffice
|
||||
t.Cleanup(func() {
|
||||
config.Integrations.OnlyOffice = orig
|
||||
})
|
||||
|
||||
config.Integrations.OnlyOffice.Url = "http://192.168.88.100:8282"
|
||||
config.Integrations.OnlyOffice.InternalUrl = "http://onlyoffice"
|
||||
|
||||
cachePath := "/cache/files/data/doc_1/output.ods/output.ods?md5=abc&expires=1"
|
||||
publicURL := "http://192.168.88.100:8282" + cachePath
|
||||
internalURL := "http://onlyoffice" + cachePath
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "rewrites public cache URL to internal host",
|
||||
input: publicURL,
|
||||
want: internalURL,
|
||||
},
|
||||
{
|
||||
name: "empty URL",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "untrusted host rejected",
|
||||
input: "http://other-host:8282" + cachePath,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "non-http scheme rejected",
|
||||
input: "ftp://192.168.88.100:8282" + cachePath,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveOnlyOfficeDownloadURL(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("pass through public URL when internalUrl unset", func(t *testing.T) {
|
||||
config.Integrations.OnlyOffice.InternalUrl = ""
|
||||
got := resolveOnlyOfficeDownloadURL(publicURL)
|
||||
if got != publicURL {
|
||||
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want %q", got, publicURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hostname match with default http port", func(t *testing.T) {
|
||||
config.Integrations.OnlyOffice.Url = "http://office.local"
|
||||
config.Integrations.OnlyOffice.InternalUrl = "http://onlyoffice"
|
||||
input := "http://office.local:80" + cachePath
|
||||
want := "http://onlyoffice" + cachePath
|
||||
got := resolveOnlyOfficeDownloadURL(input)
|
||||
if got != want {
|
||||
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reject when public url not configured", func(t *testing.T) {
|
||||
config.Integrations.OnlyOffice.Url = ""
|
||||
config.Integrations.OnlyOffice.InternalUrl = "http://onlyoffice"
|
||||
got := resolveOnlyOfficeDownloadURL(publicURL)
|
||||
if got != "" {
|
||||
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteOfficeId(t *testing.T) {
|
||||
const rawPath = "/docs/document.docx"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resolve func(utils.FileOptions) (*iteminfo.ExtendedFileInfo, error)
|
||||
deleteKey string
|
||||
remainKey string
|
||||
}{
|
||||
{
|
||||
name: "deletes resolved realpath",
|
||||
resolve: func(utils.FileOptions) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return &iteminfo.ExtendedFileInfo{RealPath: "/some/path/document.docx"}, nil
|
||||
},
|
||||
deleteKey: "/some/path/document.docx",
|
||||
remainKey: rawPath,
|
||||
},
|
||||
{
|
||||
name: "fallback to raw path on error",
|
||||
resolve: func(utils.FileOptions) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return nil, fmt.Errorf("could not resolve path")
|
||||
},
|
||||
deleteKey: rawPath,
|
||||
remainKey: "/some/path/document.docx",
|
||||
},
|
||||
{
|
||||
name: "also deletes realpath when partially resolved before erroring",
|
||||
resolve: func(utils.FileOptions) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return &iteminfo.ExtendedFileInfo{RealPath: "/some/path/document.docx"}, fmt.Errorf("access check failed")
|
||||
},
|
||||
deleteKey: "/some/path/document.docx",
|
||||
remainKey: rawPath,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
origStore := store
|
||||
store = &bolt.BoltStore{Access: &access.Storage{}, Share: &share.Storage{}}
|
||||
t.Cleanup(func() { store = origStore })
|
||||
|
||||
origFunc := files.FileInfoFasterFunc
|
||||
t.Cleanup(func() { files.FileInfoFasterFunc = origFunc })
|
||||
files.FileInfoFasterFunc = func(opts utils.FileOptions, accessStore *access.Storage, user *users.User, shareStore *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
if opts.Path != rawPath {
|
||||
t.Errorf("expected opts.Path=%q, got %q", rawPath, opts.Path)
|
||||
}
|
||||
if opts.Source != "source" {
|
||||
t.Errorf("expected opts.Source=%q, got %q", "source", opts.Source)
|
||||
}
|
||||
if !opts.FollowSymlinks {
|
||||
t.Error("expected FollowSymlinks=true")
|
||||
}
|
||||
return tt.resolve(opts)
|
||||
}
|
||||
utils.OnlyOfficeCache.Set(tt.deleteKey, "document-key")
|
||||
utils.OnlyOfficeCache.Set(tt.remainKey, "other-key")
|
||||
t.Cleanup(func() {
|
||||
utils.OnlyOfficeCache.Delete(tt.deleteKey)
|
||||
utils.OnlyOfficeCache.Delete(tt.remainKey)
|
||||
})
|
||||
deleteOfficeId("source", rawPath, &users.User{})
|
||||
if _, err := getOnlyOfficeId(tt.deleteKey); err == nil {
|
||||
t.Errorf("expected cache entry %q to be deleted", tt.deleteKey)
|
||||
}
|
||||
if _, err := getOnlyOfficeId(tt.remainKey); err != nil {
|
||||
t.Errorf("expected cache entry %q to remain, but it was deleted", tt.remainKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyOfficeURLHostsMatch(t *testing.T) {
|
||||
mustParse := func(raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("url.Parse(%q): %v", raw, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
if !onlyOfficeURLHostsMatch(mustParse("http://office.local:80/x"), mustParse("http://office.local/x")) {
|
||||
t.Fatal("expected office.local:80 to match office.local with default port")
|
||||
}
|
||||
if onlyOfficeURLHostsMatch(mustParse("http://evil.example/x"), mustParse("http://office.local/x")) {
|
||||
t.Fatal("expected different hostnames not to match")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
)
|
||||
|
||||
// pinnedItemPatchRequest is the JSON body for PATCH /api/users/pinnedItems.
|
||||
type pinnedItemPatchRequest struct {
|
||||
Source string `json:"source" validate:"required"`
|
||||
Path string `json:"path" validate:"required"` // scope-relative parent directory
|
||||
Name string `json:"name" validate:"required"` // item basename within path
|
||||
}
|
||||
|
||||
// sharePinnedItemPatchRequest is the JSON body for PATCH /public/api/share/pinnedItems.
|
||||
type sharePinnedItemPatchRequest struct {
|
||||
Path string `json:"path" validate:"required"` // share-relative parent directory
|
||||
Name string `json:"name" validate:"required"` // item basename within path
|
||||
}
|
||||
|
||||
// scopeRelativeDirToIndexPath maps a scope-relative directory to source and index paths.
|
||||
func scopeRelativeDirToIndexPath(sourceName, userScope, directoryPath string) (sourcePath, indexDirPath string, err error) {
|
||||
userScope = strings.TrimRight(userScope, "/")
|
||||
source, ok := users.ResolveSourceKey(sourceName)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("source not found: %s", sourceName)
|
||||
}
|
||||
idx := indexing.GetIndex(source.Name)
|
||||
if idx == nil {
|
||||
return "", "", fmt.Errorf("index not found for source %s", source.Name)
|
||||
}
|
||||
|
||||
cleanDir, err := utils.SanitizeUserPath(directoryPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
fullDirPath := utils.JoinPathAsUnix(userScope, cleanDir)
|
||||
return source.Path, idx.MakeIndexPath(fullDirPath, true), nil
|
||||
}
|
||||
|
||||
// normalizeShareRelativeDir returns a normalized share-relative directory path key.
|
||||
func normalizeShareRelativeDir(shareRelDir string) (string, error) {
|
||||
if shareRelDir == "" || shareRelDir == "/" {
|
||||
return "/", nil
|
||||
}
|
||||
cleanDir, err := utils.SanitizeUserPath(shareRelDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
return utils.AddTrailingSlashIfNotExists(cleanDir), nil
|
||||
}
|
||||
|
||||
// pinnedItemAction returns the patch action query param, defaulting to "add".
|
||||
func pinnedItemAction(r *http.Request) string {
|
||||
action := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("action")))
|
||||
if action == "" {
|
||||
return "add"
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
// userPatchPinnedItemsHandler adds or removes a single pinned item for the authenticated user.
|
||||
// @Summary Add or remove a pinned item
|
||||
// @Description Patches one pinned item at a time. Defaults to add; pass ?action=remove to unpin.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param action query string false "add (default) or remove"
|
||||
// @Param body body pinnedItemPatchRequest true "Pinned item"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users/pinnedItems [patch]
|
||||
func userPatchPinnedItemsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
var body pinnedItemPatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
action := pinnedItemAction(r)
|
||||
if action != "add" && action != "remove" {
|
||||
return http.StatusBadRequest, fmt.Errorf("action must be add or remove")
|
||||
}
|
||||
|
||||
if body.Source == "" || body.Path == "" || body.Name == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("source, path, and name are required")
|
||||
}
|
||||
cleanPath, err := utils.SanitizeUserPath(body.Path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
body.Path = cleanPath
|
||||
|
||||
cleanName, err := utils.SanitizeUserPath(body.Name)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
body.Name = cleanName
|
||||
|
||||
source, ok := users.ResolveSourceKey(body.Source)
|
||||
if !ok {
|
||||
return http.StatusBadRequest, fmt.Errorf("source not found: %s", body.Source)
|
||||
}
|
||||
if !d.user.HasSourceByPath(source.Path) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to source %s", body.Source)
|
||||
}
|
||||
|
||||
userScope, err := d.user.GetScopeForSourcePath(source.Path)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
sourcePath, indexDirPath, err := scopeRelativeDirToIndexPath(body.Source, userScope, body.Path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
u, err := store.Users.Get(d.user.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
pinned := u.EnsurePinnedItems()
|
||||
switch action {
|
||||
case "add":
|
||||
pinned.Add(sourcePath, indexDirPath, body.Name)
|
||||
case "remove":
|
||||
pinned.Remove(sourcePath, indexDirPath, body.Name)
|
||||
}
|
||||
|
||||
if err := store.Users.Update(u, d.user.Permissions.Admin, "PinnedItems"); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return http.StatusNoContent, nil
|
||||
}
|
||||
+35
-15
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
@@ -85,6 +84,22 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func shouldServeOriginalPreview(ext string, resizable bool, realPath, previewSize string, fileSize int64) bool {
|
||||
if !resizable {
|
||||
return false
|
||||
}
|
||||
// HEIC/TIFF still need conversion for reliable web display.
|
||||
switch ext {
|
||||
case ".heic", ".heif", ".tiff", ".tif":
|
||||
return false
|
||||
}
|
||||
const maxSizeForOriginal = 256 * 1024 // 256KB
|
||||
if config.Server.DisableResize || fileSize < maxSizeForOriginal {
|
||||
return true
|
||||
}
|
||||
return preview.ShouldServeOriginalImage(realPath, previewSize)
|
||||
}
|
||||
|
||||
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
|
||||
fd, err := os.Open(file.RealPath)
|
||||
if err != nil {
|
||||
@@ -105,7 +120,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
|
||||
// Build list of previewable item names in stable order (same as d.fileInfo.Files)
|
||||
var previewableNames []string
|
||||
for _, item := range d.fileInfo.Files {
|
||||
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
if !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
continue
|
||||
}
|
||||
previewableNames = append(previewableNames, item.Name)
|
||||
@@ -121,7 +136,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
|
||||
path := utils.JoinPathAsUnix(d.fileInfo.Path, name)
|
||||
user := d.user
|
||||
if d.share != nil {
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
|
||||
sourceInfo, ok := config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source not found for share")
|
||||
}
|
||||
@@ -156,6 +171,11 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
|
||||
}
|
||||
|
||||
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
ctx := r.Context()
|
||||
if d.ctx != nil {
|
||||
ctx = d.ctx
|
||||
}
|
||||
|
||||
previewSize := r.URL.Query().Get("size")
|
||||
if !(previewSize == "large" || previewSize == "original" || previewSize == "xlarge") {
|
||||
previewSize = "small"
|
||||
@@ -192,6 +212,12 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
}
|
||||
fileInfo, err := getDirectoryPreview(r, d, dirFrameIndex)
|
||||
if err != nil {
|
||||
if isClientCancellation(ctx, err) {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
if ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) {
|
||||
return http.StatusRequestTimeout, fmt.Errorf("preview generation timed out")
|
||||
}
|
||||
logger.Errorf("error getting directory preview: %v", err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -204,9 +230,9 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
|
||||
resizable := iteminfo.ResizableImageTypes[ext]
|
||||
|
||||
// For small, displayable images (jpg, png, etc.) serve the original to avoid processing.
|
||||
const maxSizeForOriginal = 128 * 1024 // 128KB
|
||||
if resizable && (config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
|
||||
// For small displayable images (jpg, png, etc.) serve the original to avoid processing.
|
||||
// Also serve the original when dimensions already fit the requested preview size.
|
||||
if isImage && shouldServeOriginalPreview(ext, resizable, d.fileInfo.RealPath, previewSize, d.fileInfo.Size) {
|
||||
return rawFileHandler(w, r, d.fileInfo)
|
||||
}
|
||||
|
||||
@@ -214,7 +240,7 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
if d.fileInfo.OnlyOfficeId != "" {
|
||||
pathUrl := fmt.Sprintf("/api/resources/download?file=%s&source=%s", url.QueryEscape(d.fileInfo.Path), url.QueryEscape(d.fileInfo.Source))
|
||||
pathUrl = pathUrl + "&auth=" + d.token
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
if config.Server.InternalUrl != "" {
|
||||
officeUrl = config.Server.InternalUrl + pathUrl
|
||||
} else {
|
||||
scheme := "http"
|
||||
@@ -224,12 +250,6 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
officeUrl = scheme + "://" + r.Host + pathUrl
|
||||
}
|
||||
}
|
||||
// Use the context from the request context (which includes timeout)
|
||||
ctx := r.Context()
|
||||
if d.ctx != nil {
|
||||
ctx = d.ctx
|
||||
}
|
||||
|
||||
previewImg, err := preview.GetPreviewForFile(ctx, d.fileInfo, previewSize, officeUrl, seekPercentage)
|
||||
if err != nil {
|
||||
// Check if it was a context cancellation (client navigated away)
|
||||
@@ -240,8 +260,8 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
|
||||
// Check if it was a context timeout (server-side timeout)
|
||||
if ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) {
|
||||
logger.Errorf("Preview timeout for file '%s' after 60 seconds", d.fileInfo.Name)
|
||||
return http.StatusRequestTimeout, fmt.Errorf("preview generation timed out after 60 seconds")
|
||||
logger.Errorf("Preview timeout for file '%s' after 15 seconds", d.fileInfo.Name)
|
||||
return http.StatusRequestTimeout, fmt.Errorf("preview generation timed out after 15 seconds")
|
||||
}
|
||||
|
||||
// Log detailed error information for actual server errors
|
||||
|
||||
+12
-4
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"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/preview"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -80,7 +79,7 @@ func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
}
|
||||
|
||||
// Get the actual source name from the share's source mapping
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
|
||||
sourceInfo, ok := config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return http.StatusInternalServerError, fmt.Errorf("source not found for share")
|
||||
}
|
||||
@@ -368,10 +367,19 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// Note: Share paths are absolute, so we don't strip user scope here
|
||||
// resourcePatchHandler will skip adding scope for shares
|
||||
for i := range req.Items {
|
||||
var sanitizedPath string
|
||||
sanitizedPath, err = utils.SanitizeUserPath(req.Items[i].FromPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid from path: %w", err)
|
||||
}
|
||||
req.Items[i].FromSource = sourceName
|
||||
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].FromPath)
|
||||
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, sanitizedPath)
|
||||
sanitizedPath, err = utils.SanitizeUserPath(req.Items[i].ToPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid to path: %w", err)
|
||||
}
|
||||
req.Items[i].ToSource = sourceName
|
||||
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].ToPath)
|
||||
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, sanitizedPath)
|
||||
}
|
||||
d.Data = req
|
||||
|
||||
|
||||
+117
-26
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"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/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
@@ -99,10 +98,12 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
Expand: true,
|
||||
Content: getContent,
|
||||
Metadata: getMetadata,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
HideFileExt: d.user.HideFileExt,
|
||||
SkipExtendedAttrs: skipExtendedAttrs,
|
||||
ShowSharedAttr: true,
|
||||
ShowPinnedItems: true,
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
@@ -145,18 +146,28 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
if !d.user.Permissions.Delete {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete")
|
||||
}
|
||||
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
|
||||
if path == "/" {
|
||||
return http.StatusForbidden, fmt.Errorf("cannot delete your user's root directory")
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
|
||||
if idx.Config.ReadOnly {
|
||||
return http.StatusForbidden, fmt.Errorf("source is read-only")
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
HideFileExt: d.user.HideFileExt,
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
@@ -251,43 +262,78 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
|
||||
// Process each item one at a time
|
||||
for _, item := range items {
|
||||
rawPath := item.Path
|
||||
// Validate item
|
||||
if item.Path == "" {
|
||||
if rawPath == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: rawPath,
|
||||
Message: "path was empty",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Prevent deletion of root
|
||||
if item.Path == "/" {
|
||||
if rawPath == "/" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: rawPath,
|
||||
Message: "cannot delete root directory",
|
||||
})
|
||||
continue
|
||||
}
|
||||
sanitizedPath, err := utils.SanitizeUserPath(rawPath)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if d.share != nil {
|
||||
sourceName := d.share.GetSourceName()
|
||||
if sourceName == "" {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Message: "source name is empty",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Message: "source not found",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if idx.Config.ReadOnly {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Message: "source is read-only",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// get user scope path from share
|
||||
userScope, err := d.shareUser.GetScopeForSourceName(sourceName)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "user does not have access",
|
||||
})
|
||||
continue
|
||||
}
|
||||
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
|
||||
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
|
||||
indexPath := utils.JoinPathAsUnix(withoutUserScope, sanitizedPath)
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
@@ -305,7 +351,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "error deleting file/directory, admin must check the logs",
|
||||
})
|
||||
continue
|
||||
@@ -317,7 +363,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if item.Source == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "source was empty, source is required",
|
||||
})
|
||||
continue
|
||||
@@ -328,7 +374,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: fmt.Sprintf("user does not have access: %v", err),
|
||||
})
|
||||
continue
|
||||
@@ -338,23 +384,32 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if idx == nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "source not found",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if idx.Config.ReadOnly {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Message: "source is read-only",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Get file info
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Source: item.Source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, filePermUser, store.Share)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
@@ -363,7 +418,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
@@ -481,6 +536,17 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
if d.share == nil && !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
logger.Debugf("source %s not found", source)
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
|
||||
if idx.Config.ReadOnly {
|
||||
return http.StatusForbidden, fmt.Errorf("source is read-only")
|
||||
}
|
||||
|
||||
filePermUser := d.user
|
||||
if d.share != nil {
|
||||
filePermUser = d.shareUser
|
||||
@@ -492,11 +558,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
Expand: false,
|
||||
FollowSymlinks: true,
|
||||
}
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
logger.Debugf("source %s not found", source)
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
|
||||
userscope, err := filePermUser.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
@@ -521,6 +582,10 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
existingIsDir := stat.IsDir()
|
||||
requestingDir := isDir
|
||||
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
|
||||
// If type mismatch (file vs folder or folder vs file) and not overriding
|
||||
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("Type conflict detected in chunked: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
|
||||
@@ -706,6 +771,9 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
if idx.Config.ReadOnly {
|
||||
return http.StatusForbidden, fmt.Errorf("source is read-only")
|
||||
}
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, fullIndexPath, d.user.Username) {
|
||||
logger.Debugf("user %s denied access to path %s", d.user.Username, fullIndexPath)
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
@@ -839,6 +907,28 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
continue
|
||||
}
|
||||
|
||||
if srcIdx.Config.ReadOnly && req.Action == "move" {
|
||||
item.Message = "From source is read-only and cannot be moved"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if dstIdx.Config.ReadOnly {
|
||||
item.Message = "destination source is read-only"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build full index paths for access control
|
||||
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
|
||||
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
|
||||
@@ -1035,6 +1125,7 @@ func patchAction(ctx context.Context, params patchActionParams) error {
|
||||
Source: params.srcIndex,
|
||||
IsDir: params.isSrcDir,
|
||||
ShowHidden: params.d.user.ShowHidden,
|
||||
HideFileExt: params.d.user.HideFileExt,
|
||||
}, store.Access, params.d.user, store.Share)
|
||||
|
||||
if err != nil {
|
||||
|
||||
+204
-55
@@ -8,22 +8,31 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
type searchOptions struct {
|
||||
query string
|
||||
parsed iteminfo.SearchOptions
|
||||
sources []string
|
||||
searchScope string
|
||||
combinedPath map[string]string // source -> combinedPath
|
||||
sessionId string
|
||||
largest bool
|
||||
useWildcard bool
|
||||
olderThanUnix int64 // optional; 0 = unset. Modified time must be strictly before this Unix second.
|
||||
newerThanUnix int64 // optional; 0 = unset. Modified time must be >= this Unix second.
|
||||
}
|
||||
|
||||
// scopedSourcePath is one repeated "scope" query value using "sourceName:relativePath"
|
||||
// (split on the first ':'). Path is relative within the user's scope for that source; an
|
||||
// empty path after ':' means "/" . Example: scope=mydisk:/photos&scope=backup:/archive
|
||||
type scopedSourcePath struct {
|
||||
source string
|
||||
relPath string
|
||||
}
|
||||
|
||||
// searchHandler handles search requests for files based on the provided query.
|
||||
//
|
||||
// This endpoint processes a search query, retrieves relevant file paths, and
|
||||
@@ -31,28 +40,43 @@ type searchOptions struct {
|
||||
// against the file index, which is built from the root directory specified in
|
||||
// the server's configuration. The results are filtered based on the user's scope.
|
||||
//
|
||||
// The handler supports searching a single source (using the 'source' parameter)
|
||||
// or multiple sources (using the 'sources' parameter). When multiple sources
|
||||
// are specified, the scope is always set to the user's scope for each source.
|
||||
// Per-source search scope (preferred for multi-source):
|
||||
//
|
||||
// Repeated query parameter "scope" with the value "sourceName:relativePath",
|
||||
// split on the first colon. The path is user-relative (same as the legacy single-scope
|
||||
// path). Encode the whole value with the normal query string rules (e.g. %3A for ':' if needed).
|
||||
// Examples:
|
||||
// ?scope=mydisk:/&scope=backup:/Photos
|
||||
// Duplicate source names: the last repeated scope for that source wins.
|
||||
//
|
||||
// Legacy (still supported):
|
||||
//
|
||||
// - source / sources: which catalogues to search
|
||||
// - scope: a single path (no 'source:' prefix) applied only when exactly one source is specified.
|
||||
// When multiple sources are listed and no "source:path" scopes are used, each source is searched
|
||||
// from the root of the user's scope (same as before).
|
||||
//
|
||||
// The handler expects the following headers in the request:
|
||||
// - SessionId: A unique identifier for the user's session.
|
||||
//
|
||||
// The request URL should include query parameters:
|
||||
// - query: The search terms to use (required)
|
||||
// - query: Structured filter prefix only, or full legacy search string when "terms" parameters are not used
|
||||
// - terms: Repeated query parameter; each value is one literal search term. OR-combined by default; use termJoin=and for AND.
|
||||
// - termJoin: Optional; "and" requires every term to match; any other value keeps OR semantics (default).
|
||||
// - source: Source name (deprecated, use 'sources' instead)
|
||||
// - sources: Comma-separated list of source names (e.g., "source1,source2")
|
||||
// - scope: Optional path within user scope to search
|
||||
// - sources: Comma-separated list of source names (e.g., "source1,source2") when not using scoped scope=source:path params
|
||||
// - scope: Either (1) repeated "sourceName:relativePath" as above, or (2) legacy single path within user scope for a single-source search
|
||||
// - olderThan: Optional Unix time in seconds; only items modified strictly before this instant
|
||||
// - newerThan: Optional Unix time in seconds; only items modified on or after this instant
|
||||
// - useWildcard: Optional; when true, file names are matched with SQLite GLOB (wildcards) instead of substring search. Legacy query params glob and useGlob are accepted as aliases.
|
||||
//
|
||||
// Example request (single source):
|
||||
//
|
||||
// GET api/tools/search?query=myfile&source=mysource
|
||||
//
|
||||
// Example request (multiple sources):
|
||||
// Example request (multiple sources with per-source folders):
|
||||
//
|
||||
// GET api/tools/search?query=myfile&sources=source1,source2
|
||||
// GET api/tools/search?query=myfile&scope=source1:/&scope=source2:/documents
|
||||
//
|
||||
// Example response:
|
||||
// [
|
||||
@@ -75,13 +99,17 @@ type searchOptions struct {
|
||||
// @Tags Tools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param query query string true "Search query"
|
||||
// @Param query query string false "Filter prefix or full legacy search text (required when no terms are supplied)"
|
||||
// @Param terms query []string false "Repeated: one literal search term per parameter; combined with OR unless termJoin=and"
|
||||
// @Param source query string false "Source name for the desired source (deprecated, use 'sources' instead)"
|
||||
// @Param sources query string false "Comma-separated list of source names to search across multiple sources. When multiple sources are specified, scope is always the user's scope for each source."
|
||||
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only. Ignored when multiple sources are specified."
|
||||
// @Param sources query string false "Comma-separated source names when not using repeated scope=source:path"
|
||||
// @Param scope query []string false "Repeated: either 'sourceName:relativePath' per source, or legacy single path when one source"
|
||||
// @Param olderThan query int false "Unix seconds; only results modified strictly before this time"
|
||||
// @Param newerThan query int false "Unix seconds; only results modified on or after this time"
|
||||
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
|
||||
// @Param useWildcard query bool false "When true, match indexed file names with SQLite GLOB (wildcard patterns)"
|
||||
// @Param glob query bool false "Deprecated: alias for useWildcard"
|
||||
// @Param useGlob query bool false "Deprecated: alias for useWildcard"
|
||||
// @Param termJoin query string false "Optional: 'and' to require all repeated 'terms' match; default is OR"
|
||||
// @Success 200 {array} indexing.SearchResult "List of search results with source field populated"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Router /api/tools/search [get]
|
||||
@@ -104,10 +132,10 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.sources[0])
|
||||
}
|
||||
combinedPath := searchOptions.combinedPath[searchOptions.sources[0]]
|
||||
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
|
||||
response = index.SearchParsed(searchOptions.parsed, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix, searchOptions.useWildcard)
|
||||
} else {
|
||||
// Multiple sources - use the new SearchMultiSources function
|
||||
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
|
||||
response = indexing.SearchMultiSourcesParsed(searchOptions.parsed, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix, searchOptions.useWildcard)
|
||||
}
|
||||
|
||||
// Filter out items that are not permitted according to access rules and trim user scope from paths
|
||||
@@ -125,16 +153,85 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
result.Path = "/"
|
||||
}
|
||||
filteredResponse = append(filteredResponse, result)
|
||||
// This is to filter the ext-hidden files from search results, like the ones with the hidden property
|
||||
if d.user.HideFileExt != "" {
|
||||
filtered := filteredResponse[:0]
|
||||
for _, res := range filteredResponse {
|
||||
if res.Type == "directory" {
|
||||
filtered = append(filtered, res)
|
||||
continue
|
||||
}
|
||||
baseName := filepath.Base(res.Path)
|
||||
if !utils.HideFileByExt(baseName, d.user.HideFileExt) {
|
||||
filtered = append(filtered, res)
|
||||
}
|
||||
}
|
||||
filteredResponse = filtered
|
||||
}
|
||||
}
|
||||
return renderJSON(w, r, filteredResponse)
|
||||
}
|
||||
|
||||
// parseRepeatedScopeParams interprets repeated "scope" query values.
|
||||
// If a value contains ':', it is "sourceName:relativePath" (first ':' separates source and path).
|
||||
// Otherwise it is a legacy single path (at most one such value across all scope params).
|
||||
func parseRepeatedScopeParams(scopeQueryValues []string) ([]scopedSourcePath, string, error) {
|
||||
var clauses []scopedSourcePath
|
||||
legacyPath := ""
|
||||
for _, raw := range scopeQueryValues {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
idx := strings.IndexByte(raw, ':')
|
||||
if idx > 0 {
|
||||
src := strings.TrimSpace(raw[:idx])
|
||||
if src == "" {
|
||||
return nil, "", fmt.Errorf("invalid scope parameter %q: empty source name before ':'", raw)
|
||||
}
|
||||
pathPart := strings.TrimSpace(raw[idx+1:])
|
||||
if pathPart == "" {
|
||||
pathPart = "/"
|
||||
}
|
||||
cleanPath, err := utils.SanitizeUserPath(pathPart)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("invalid path in scope parameter %q: %v", raw, err)
|
||||
}
|
||||
clauses = append(clauses, scopedSourcePath{source: src, relPath: cleanPath})
|
||||
continue
|
||||
}
|
||||
if idx == 0 {
|
||||
return nil, "", fmt.Errorf("invalid scope parameter %q: source name must precede ':'", raw)
|
||||
}
|
||||
if legacyPath != "" {
|
||||
return nil, "", fmt.Errorf("multiple legacy scope paths without a source prefix are not allowed; use scope=sourceName:path for each source")
|
||||
}
|
||||
clean, err := utils.SanitizeUserPath(raw)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("invalid scope: %v", err)
|
||||
}
|
||||
legacyPath = clean
|
||||
}
|
||||
return clauses, legacyPath, nil
|
||||
}
|
||||
|
||||
func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, error) {
|
||||
query := r.URL.Query().Get("query")
|
||||
rawTerms := r.URL.Query()["terms"]
|
||||
termJoin := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("termJoin")))
|
||||
matchAllTerms := termJoin == "and"
|
||||
sourcesParam := r.URL.Query().Get("sources")
|
||||
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
|
||||
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
|
||||
scopeValues := r.URL.Query()["scope"] // repeated; use slice, not Get (first only)
|
||||
largest := r.URL.Query().Get("largest") == "true"
|
||||
wildRaw := strings.TrimSpace(r.URL.Query().Get("useWildcard"))
|
||||
if wildRaw == "" {
|
||||
wildRaw = strings.TrimSpace(r.URL.Query().Get("glob"))
|
||||
}
|
||||
if wildRaw == "" {
|
||||
wildRaw = strings.TrimSpace(r.URL.Query().Get("useGlob"))
|
||||
}
|
||||
useWildcard := strings.EqualFold(wildRaw, "true") || wildRaw == "1"
|
||||
olderThanUnix, err := parseOptionalUnixQueryParam("olderThan", r.URL.Query().Get("olderThan"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -143,64 +240,116 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "/"
|
||||
}
|
||||
cleanScope, err := utils.SanitizeUserPath(scope)
|
||||
|
||||
scopedClauses, legacyScopeOnly, err := parseRepeatedScopeParams(scopeValues)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scope: %v", err)
|
||||
}
|
||||
scope = cleanScope
|
||||
|
||||
var sources []string
|
||||
if sourcesParam != "" {
|
||||
sources = strings.Split(sourcesParam, ",")
|
||||
} else if sourceParam != "" {
|
||||
sources = []string{sourceParam}
|
||||
} else {
|
||||
return nil, fmt.Errorf("either 'source' or 'sources' query parameter is required")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate all sources exist
|
||||
for _, source := range sources {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return nil, fmt.Errorf("index not found for source %s", source)
|
||||
normalizedTerms := make([]string, 0, len(rawTerms))
|
||||
for _, t := range rawTerms {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
normalizedTerms = append(normalizedTerms, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(query) < settings.Config.Server.MinSearchLength && !largest {
|
||||
return nil, fmt.Errorf("query is too short, minimum length is %d", settings.Config.Server.MinSearchLength)
|
||||
}
|
||||
searchScope := strings.TrimPrefix(scope, ".")
|
||||
parsed := iteminfo.BuildSearchOptionsFromQuery(query, normalizedTerms, matchAllTerms)
|
||||
|
||||
// If multiple sources, always use user scope (ignore searchScope)
|
||||
if len(sources) > 1 {
|
||||
searchScope = ""
|
||||
minLen := config.Server.MinSearchLength
|
||||
if !largest {
|
||||
if len(normalizedTerms) > 0 {
|
||||
for _, t := range normalizedTerms {
|
||||
if len(t) < minLen {
|
||||
return nil, fmt.Errorf("each term is too short, minimum length is %d", minLen)
|
||||
}
|
||||
}
|
||||
} else if len(strings.TrimSpace(query)) < minLen {
|
||||
return nil, fmt.Errorf("query is too short, minimum length is %d", minLen)
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve the User-Agent and X-Auth headers from the request
|
||||
sessionId := r.Header.Get("SessionId")
|
||||
|
||||
// Build combinedPath map for each source
|
||||
var sources []string
|
||||
combinedPathMap := make(map[string]string)
|
||||
for _, source := range sources {
|
||||
index := indexing.GetIndex(source)
|
||||
userscope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var searchScopeOut string
|
||||
|
||||
if len(scopedClauses) > 0 {
|
||||
if legacyScopeOnly != "" {
|
||||
return nil, fmt.Errorf("cannot mix legacy scope path with scope=sourceName:path parameters")
|
||||
}
|
||||
pathBySource := make(map[string]string)
|
||||
for _, c := range scopedClauses {
|
||||
if _, ok := pathBySource[c.source]; !ok {
|
||||
sources = append(sources, c.source)
|
||||
}
|
||||
pathBySource[c.source] = c.relPath
|
||||
}
|
||||
for _, source := range sources {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return nil, fmt.Errorf("index not found for source %s", source)
|
||||
}
|
||||
userscope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel := strings.TrimPrefix(pathBySource[source], ".")
|
||||
combinedPathMap[source] = index.MakeIndexPath(filepath.Join(userscope, rel), true)
|
||||
}
|
||||
} else {
|
||||
if sourcesParam != "" {
|
||||
sources = strings.Split(sourcesParam, ",")
|
||||
} else if sourceParam != "" {
|
||||
sources = []string{sourceParam}
|
||||
} else {
|
||||
return nil, fmt.Errorf("either 'source', 'sources', or repeated scope=sourceName:path query parameters are required")
|
||||
}
|
||||
for i := range sources {
|
||||
sources[i] = strings.TrimSpace(sources[i])
|
||||
}
|
||||
|
||||
for _, source := range sources {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return nil, fmt.Errorf("index not found for source %s", source)
|
||||
}
|
||||
}
|
||||
|
||||
scope := legacyScopeOnly
|
||||
if scope == "" {
|
||||
scope = "/"
|
||||
}
|
||||
cleanScope, err := utils.SanitizeUserPath(scope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scope: %v", err)
|
||||
}
|
||||
searchScopeOut = strings.TrimPrefix(cleanScope, ".")
|
||||
|
||||
if len(sources) > 1 {
|
||||
searchScopeOut = ""
|
||||
}
|
||||
|
||||
for _, source := range sources {
|
||||
index := indexing.GetIndex(source)
|
||||
userscope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScopeOut), true)
|
||||
combinedPathMap[source] = combinedPath
|
||||
}
|
||||
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope), true) // searchScope is a directory
|
||||
combinedPathMap[source] = combinedPath
|
||||
}
|
||||
|
||||
return &searchOptions{
|
||||
query: query,
|
||||
parsed: parsed,
|
||||
sources: sources,
|
||||
searchScope: searchScope,
|
||||
searchScope: searchScopeOut,
|
||||
combinedPath: combinedPathMap,
|
||||
sessionId: sessionId,
|
||||
largest: largest,
|
||||
useWildcard: useWildcard,
|
||||
olderThanUnix: olderThanUnix,
|
||||
newerThanUnix: newerThanUnix,
|
||||
}, nil
|
||||
|
||||
+82
-1
@@ -206,6 +206,12 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
return http.StatusBadRequest, fmt.Errorf("hash and path are required")
|
||||
}
|
||||
|
||||
sanitizedPath, err := utils.SanitizeUserPath(body.Path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
body.Path = sanitizedPath
|
||||
|
||||
// only allow users to update their own shares
|
||||
thisShare, err := store.Share.GetByHash(body.Hash)
|
||||
if err != nil {
|
||||
@@ -215,7 +221,7 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
|
||||
}
|
||||
// Update the share path
|
||||
err = store.Share.UpdateSharePath(body.Hash, body.Path)
|
||||
err = store.Share.UpdateSharePath(body.Hash, sanitizedPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -694,3 +700,78 @@ func redirectToShare(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
|
||||
return http.StatusMovedPermanently, nil
|
||||
}
|
||||
|
||||
// publicSharePatchPinnedItemsHandler adds or removes a pinned item on a share link.
|
||||
// @Summary Add or remove a pinned item on a share
|
||||
// @Description Patches one pinned item at a time for share owners. Defaults to add; pass ?action=remove to unpin.
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash"
|
||||
// @Param action query string false "add (default) or remove"
|
||||
// @Param body body sharePinnedItemPatchRequest true "Pinned item"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Share not found"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /public/api/share/pinnedItems [patch]
|
||||
func sharePatchPinnedItemsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
if hash == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("hash is required")
|
||||
}
|
||||
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("share hash not found")
|
||||
}
|
||||
|
||||
if d.user.Username == "anonymous" || !link.UserCanEdit(d.user) {
|
||||
return http.StatusForbidden, fmt.Errorf("share pin editing is not allowed for this user")
|
||||
}
|
||||
|
||||
if link.ShareType == "upload" {
|
||||
return http.StatusForbidden, fmt.Errorf("pinning is disabled for upload shares")
|
||||
}
|
||||
|
||||
var body sharePinnedItemPatchRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
action := pinnedItemAction(r)
|
||||
if action != "add" && action != "remove" {
|
||||
return http.StatusBadRequest, fmt.Errorf("action must be add or remove")
|
||||
}
|
||||
|
||||
if body.Path == "" || body.Name == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and name are required")
|
||||
}
|
||||
|
||||
shareRelDir, err := normalizeShareRelativeDir(body.Path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %s", body.Path)
|
||||
}
|
||||
|
||||
cleanName, err := utils.SanitizeUserPath(body.Name)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid name: %s", body.Name)
|
||||
}
|
||||
body.Name = cleanName
|
||||
|
||||
pinned := link.EnsurePinnedItems()
|
||||
switch action {
|
||||
case "add":
|
||||
pinned.Add(shareRelDir, body.Name)
|
||||
case "remove":
|
||||
pinned.Remove(shareRelDir, body.Name)
|
||||
}
|
||||
|
||||
if err := store.Share.Save(link); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to save share: %w", err)
|
||||
}
|
||||
|
||||
return http.StatusNoContent, nil
|
||||
}
|
||||
+85
-70
@@ -6,6 +6,7 @@ import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -83,7 +84,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
staticURL := config.Server.BaseURL + "public/static"
|
||||
description := config.Frontend.Description
|
||||
title := config.Frontend.Name
|
||||
banner := staticURL + "/pwa-icon-512.png" // Use largest generated icon for best quality
|
||||
banner := staticURL + "/icons/pwa-icon-512.png" // largest generated PWA icon
|
||||
disableSidebar := false
|
||||
|
||||
// Use custom favicon if configured and validated, otherwise fall back to default
|
||||
@@ -95,8 +96,19 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
favicon := staticURL + "/favicon" + faviconExt
|
||||
shareHash := ""
|
||||
data := make(map[string]interface{})
|
||||
disableNavButtons := settings.Config.Frontend.DisableNavButtons
|
||||
if d.share != nil && d.shareValid {
|
||||
disableNavButtons := config.Frontend.DisableNavButtons
|
||||
if d.share != nil {
|
||||
shareHash = d.share.Hash
|
||||
}
|
||||
if shareHash == "" {
|
||||
shareHash = icons.ShareHashFromHTTPPath(r.URL.Path, config.Server.BaseURL)
|
||||
}
|
||||
if shareHash != "" && d.share == nil && store != nil {
|
||||
if link, err := store.Share.GetByHash(shareHash); err == nil {
|
||||
d.share = link
|
||||
}
|
||||
}
|
||||
if d.share != nil {
|
||||
if d.share.Favicon != "" {
|
||||
if strings.HasPrefix(d.share.Favicon, "http") {
|
||||
favicon = d.share.Favicon
|
||||
@@ -124,7 +136,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if shareFavicon != "" {
|
||||
favicon = shareFavicon
|
||||
}
|
||||
shareHash = d.share.Hash
|
||||
}
|
||||
// Set login icon URL
|
||||
loginIcon := staticURL + "/loginIcon"
|
||||
@@ -139,9 +150,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
|
||||
// Determine OpenGraph image: use banner if set, otherwise use largest available icon (512x512)
|
||||
ogImage := banner
|
||||
if banner == staticURL+"/pwa-icon-512.png" {
|
||||
if banner == staticURL+"/icons/pwa-icon-512.png" {
|
||||
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) but square works fine
|
||||
ogImage = staticURL + "/pwa-icon-512.png"
|
||||
ogImage = staticURL + "/icons/pwa-icon-512.png"
|
||||
}
|
||||
|
||||
// Construct the full URL for the current request
|
||||
@@ -158,21 +169,22 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
fullURL = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path)
|
||||
}
|
||||
|
||||
// Determine PWA icon URLs based on custom favicon settings
|
||||
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
|
||||
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
|
||||
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
|
||||
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
|
||||
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
|
||||
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
|
||||
|
||||
// If custom favicon is set and it's SVG, use the favicon directly
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
|
||||
pwaIcon192 = favicon
|
||||
pwaIcon256 = favicon
|
||||
pwaIcon512 = favicon
|
||||
} else if settings.Env.FaviconIsCustom {
|
||||
// For custom PNG/ICO favicons, use generated PWA icons
|
||||
pwaIcon192 = staticURL + "/pwa-icon-192.png"
|
||||
pwaIcon256 = staticURL + "/pwa-icon-256.png"
|
||||
pwaIcon512 = staticURL + "/pwa-icon-512.png"
|
||||
manifestURL := staticURL + "/site.webmanifest"
|
||||
if shareHash != "" {
|
||||
pwaStartURL := config.Server.BaseURL + "public/share/" + shareHash + "/"
|
||||
query := url.Values{}
|
||||
query.Set("start", pwaStartURL)
|
||||
if title != config.Frontend.Name {
|
||||
query.Set("name", title)
|
||||
}
|
||||
if description != config.Frontend.Description {
|
||||
query.Set("description", description)
|
||||
}
|
||||
manifestURL = staticURL + "/site.webmanifest?" + query.Encode()
|
||||
}
|
||||
|
||||
data["htmlVars"] = map[string]interface{}{
|
||||
@@ -186,8 +198,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"favicon": favicon,
|
||||
"loginIcon": loginIcon,
|
||||
"color": defaultThemeColor,
|
||||
"winIcon": staticURL + "/mstile-256x256.png",
|
||||
"appIcon": staticURL + "/apple-touch-icon.png",
|
||||
"winIcon": staticURL + "/icons/mstile-256x256.png",
|
||||
"appIcon": staticURL + "/icons/apple-touch-icon.png",
|
||||
"description": description,
|
||||
"loadingSpinnersCSS": template.CSS(loadingSpinnersCSS),
|
||||
"banner": banner,
|
||||
@@ -196,41 +208,43 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"pwaIcon192": pwaIcon192,
|
||||
"pwaIcon256": pwaIcon256,
|
||||
"pwaIcon512": pwaIcon512,
|
||||
"manifestURL": staticURL + "/site.webmanifest",
|
||||
"manifestURL": manifestURL,
|
||||
}
|
||||
// variables consumed by frontend as json
|
||||
|
||||
data["globalVars"] = map[string]interface{}{
|
||||
"name": config.Frontend.Name,
|
||||
"minSearchLength": config.Server.MinSearchLength,
|
||||
"disableExternal": config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.Config.UserDefaults.DarkMode,
|
||||
"baseURL": config.Server.BaseURL,
|
||||
"version": versionString,
|
||||
"commitSHA": commitSHAString,
|
||||
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
|
||||
"noAuth": config.Auth.Methods.NoAuth,
|
||||
"enableThumbs": !config.Server.DisablePreviews,
|
||||
"externalLinks": externalLinks,
|
||||
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
|
||||
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
|
||||
"jwtAvailable": config.Auth.Methods.JwtAuth.Enabled,
|
||||
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"ldapAvailable": config.Auth.Methods.LdapAuth.Enabled,
|
||||
"mediaAvailable": settings.MediaEnabled(),
|
||||
"exiftoolAvailable": settings.Config.Integrations.Media.ExiftoolPath != "",
|
||||
"muPdfAvailable": settings.Env.MuPdfAvailable,
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
|
||||
"enableHeicConversion": settings.CanConvertImage("heic"),
|
||||
"eventBasedThemes": !config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
"oidcLoginButtonText": config.Frontend.OIDCLoginButtonText,
|
||||
"loginButtonText": config.Frontend.LoginButtonText,
|
||||
"name": config.Frontend.Name,
|
||||
"minSearchLength": config.Server.MinSearchLength,
|
||||
"disableExternal": config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": config.UserDefaults.UI.DarkMode,
|
||||
"baseURL": config.Server.BaseURL,
|
||||
"version": versionString,
|
||||
"commitSHA": commitSHAString,
|
||||
"signup": config.Auth.Methods.PasswordAuth.Signup,
|
||||
"noAuth": config.Auth.Methods.NoAuth,
|
||||
"enableThumbs": !config.Server.DisablePreviews,
|
||||
"externalLinks": externalLinks,
|
||||
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": config.Integrations.OnlyOffice.Url,
|
||||
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
|
||||
"jwtAvailable": config.Auth.Methods.JwtAuth.Enabled,
|
||||
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"ldapAvailable": config.Auth.Methods.LdapAuth.Enabled,
|
||||
"mediaAvailable": settings.MediaEnabled(),
|
||||
"exiftoolAvailable": config.Integrations.Media.ExiftoolPath != "",
|
||||
"muPdfAvailable": settings.Env.MuPdfAvailable,
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
|
||||
"enableHeicConversion": settings.CanConvertImage("heic"),
|
||||
"eventBasedThemes": !config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
"oidcLoginButtonText": config.Frontend.OIDCLoginButtonText,
|
||||
"loginButtonText": config.Frontend.LoginButtonText,
|
||||
"passkeyAvailable": config.Auth.Methods.PasskeyAuth.Enabled,
|
||||
"passkeyLoginButtonText": config.Auth.Methods.PasskeyAuth.LoginButtonText,
|
||||
}
|
||||
|
||||
// Marshal each variable to JSON strings for direct template usage
|
||||
@@ -278,12 +292,21 @@ func setContentType(w http.ResponseWriter, path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// manifestHandler serves the cached PWA manifest
|
||||
// manifestHandler serves the cached PWA manifest, optionally scoped to a public share.
|
||||
func manifestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
||||
|
||||
if err := json.NewEncoder(w).Encode(icons.CachedManifest); err != nil {
|
||||
manifest := icons.CachedManifest
|
||||
if startURL := r.URL.Query().Get("start"); startURL != "" {
|
||||
name := r.URL.Query().Get("name")
|
||||
description := r.URL.Query().Get("description")
|
||||
if shareManifest, ok := icons.ManifestForShare(config.Server.BaseURL, startURL, name, description); ok {
|
||||
manifest = shareManifest
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(manifest); err != nil {
|
||||
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -313,7 +336,7 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
assetPath = settings.Env.FaviconEmbeddedPath
|
||||
case "favicon.png":
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
|
||||
iconPath := filepath.Join(settings.PWAIconsCacheDir(), "pwa-icon-512.png")
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
@@ -323,18 +346,10 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "icons/favicon-32x32.png",
|
||||
"icons/pwa-icon-192.png", "icons/pwa-icon-256.png", "icons/pwa-icon-512.png",
|
||||
"icons/apple-touch-icon.png":
|
||||
// Serve generated icons from cache directory
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
}
|
||||
// Fall back to embedded favicon.png if generation failed
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "icons/mstile-256x256.png":
|
||||
// Windows tile - redirect to pwa-icon-256.png (they're identical)
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.png")
|
||||
"icons/apple-touch-icon.png", "icons/mstile-256x256.png":
|
||||
// Files are generated as PWAIconsCacheDir()/basename (URL uses icons/ prefix only for routing)
|
||||
rel := strings.TrimPrefix(path, "icons/")
|
||||
iconPath := filepath.Join(settings.PWAIconsCacheDir(), rel)
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
|
||||
+46
-25
@@ -3,9 +3,10 @@ package http
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
)
|
||||
|
||||
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
|
||||
@@ -21,18 +22,15 @@ import (
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/otp/generate [post]
|
||||
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
username = d.user.Username
|
||||
}
|
||||
if username != d.user.Username && !d.user.Permissions.Admin {
|
||||
return http.StatusForbidden, fmt.Errorf("you are not authorized to generate OTP for this user")
|
||||
}
|
||||
targetUser, err := store.Users.Get(username)
|
||||
err := checkPassword(r, d)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
url, err := auth.GenerateOtpForUser(targetUser, store.Users)
|
||||
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
|
||||
if getErr != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
|
||||
}
|
||||
url, err := auth.GenerateOtpForUser(user, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
|
||||
}
|
||||
@@ -61,21 +59,15 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if code == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("code is required")
|
||||
}
|
||||
|
||||
targetUser := d.user
|
||||
username := r.URL.Query().Get("username")
|
||||
|
||||
// If admin is verifying for another user
|
||||
if username != "" && username != d.user.Username && d.user.Permissions.Admin {
|
||||
var err error
|
||||
targetUser, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
logger.Debugf("Admin %s verifying OTP for user: %s", d.user.Username, targetUser.Username)
|
||||
err := checkPassword(r, d)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
|
||||
err := auth.VerifyTotpCode(targetUser, code, store.Users)
|
||||
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
|
||||
if getErr != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
|
||||
}
|
||||
err = auth.VerifyTotpCode(user, code, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
@@ -86,3 +78,32 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// On success, return a simple confirmation.
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
func checkPassword(r *http.Request, d *requestContext) error {
|
||||
providedPassword := r.Header.Get("X-Password")
|
||||
username := r.URL.Query().Get("username")
|
||||
if d.user.Permissions.Admin {
|
||||
username = d.user.Username
|
||||
}
|
||||
// url decode
|
||||
providedPassword, err := url.QueryUnescape(providedPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid password encoding: %v", err)
|
||||
}
|
||||
if providedPassword == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
user, getErr := store.Users.Get(username)
|
||||
var passwordHash string
|
||||
if getErr != nil {
|
||||
passwordHash = utils.InvalidPasswordHash
|
||||
} else {
|
||||
passwordHash = user.Password
|
||||
}
|
||||
// always run checkPwd to prevent timing attacks
|
||||
err = utils.CheckPwd(providedPassword, passwordHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid password or user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
func otpRequest(username, password, code string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/otp?username="+username, http.NoBody)
|
||||
req.Header.Set("X-Password", password)
|
||||
if code != "" {
|
||||
req.Header.Set("X-Secret", code)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
oldSecret := "SOMEOLDSECRET234"
|
||||
user := &users.User{
|
||||
ID: 1,
|
||||
Username: "test",
|
||||
NonAdminEditable: users.NonAdminEditable{Password: "testPass"},
|
||||
TOTPSecret: oldSecret,
|
||||
OtpEnabled: true,
|
||||
}
|
||||
if err := store.Users.Save(user, true, true); err != nil {
|
||||
t.Fatalf("failed to save user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { auth.TotpCache.Delete(user.Username) })
|
||||
|
||||
d := &requestContext{user: user}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if _, err := generateOTPHandler(rec, otpRequest(user.Username, "testPass", ""), d); err != nil {
|
||||
t.Fatalf("genration failed: %v", err)
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
u, err := url.Parse(resp["url"])
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse url: %v", err)
|
||||
}
|
||||
newSecret := u.Query().Get("secret")
|
||||
if newSecret == oldSecret {
|
||||
t.Fatal("expected a fresh secret")
|
||||
}
|
||||
oldCode, err := totp.GenerateCode(oldSecret, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate old code: %v", err)
|
||||
}
|
||||
if _, verifyErr := verifyOTPHandler(httptest.NewRecorder(), otpRequest(user.Username, "testPass", oldCode), d); verifyErr == nil {
|
||||
t.Error("expected old secret to be rejected")
|
||||
}
|
||||
newCode, err := totp.GenerateCode(newSecret, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate new code: %v", err)
|
||||
}
|
||||
if _, verifyErr := verifyOTPHandler(httptest.NewRecorder(), otpRequest(user.Username, "testPass", newCode), d); verifyErr != nil {
|
||||
t.Fatalf("expected new secret to be accepted: %v", verifyErr)
|
||||
}
|
||||
updated, err := store.Users.Get(user.Username)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload user: %v", err)
|
||||
}
|
||||
if updated.TOTPSecret != newSecret {
|
||||
t.Error("expected TOTPSecret to be updated to the new secret")
|
||||
}
|
||||
if _, found := auth.TotpCache.Get(user.Username); found {
|
||||
t.Error("expected cache to be cleared after verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_EnforcedOtp(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
config.Auth.Key = "test-key"
|
||||
config.Auth.TokenExpirationHours = 1
|
||||
config.Auth.Methods.PasswordAuth.EnforcedOtp = true
|
||||
|
||||
user := &users.User{ID: 2, Username: "loginuser", LoginMethod: users.LoginMethodPassword}
|
||||
d := &requestContext{user: user}
|
||||
|
||||
status, err := loginHandler(httptest.NewRecorder(), otpRequest(user.Username, "", ""), d)
|
||||
if status != http.StatusForbidden || err != errors.ErrNoTotpConfigured {
|
||||
t.Fatalf("expected login without TOTP to fail, got=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
user.TOTPSecret = "SOMESECRET123456"
|
||||
if _, loginErr := loginHandler(httptest.NewRecorder(), otpRequest(user.Username, "", ""), d); loginErr != nil {
|
||||
t.Fatalf("expected login with TOTP to succed, got err=%v", loginErr)
|
||||
}
|
||||
}
|
||||
+110
-25
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -89,6 +90,7 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
return renderJSON(w, r, u)
|
||||
}
|
||||
|
||||
// prepForFrontend strips secrets and maps source identifiers to display names for JSON (never expose paths).
|
||||
func prepForFrontend(u *users.User) {
|
||||
u.Password = ""
|
||||
u.ApiKeys = nil
|
||||
@@ -97,8 +99,59 @@ func prepForFrontend(u *users.User) {
|
||||
u.TOTPSecret = ""
|
||||
u.TOTPNonce = ""
|
||||
u.Scopes = u.GetFrontendScopes()
|
||||
u.SidebarLinks = u.GetFrontendSidebarLinks()
|
||||
u.SidebarLinks = GetFrontendSidebarLinks(u.SidebarLinks, u.ShowToolsInSidebar)
|
||||
u.PinnedItems = nil
|
||||
u.Locale = normalizeLocale(u.Locale)
|
||||
for i := range u.PasskeyCredentials {
|
||||
u.PasskeyCredentials[i].PublicKey = ""
|
||||
u.PasskeyCredentials[i].AttestationType = ""
|
||||
u.PasskeyCredentials[i].AttestationFormat = ""
|
||||
u.PasskeyCredentials[i].Flags = users.WebAuthnCredentialFlags{}
|
||||
u.PasskeyCredentials[i].SignCount = 0
|
||||
u.PasskeyCredentials[i].ClientDataJSON = ""
|
||||
u.PasskeyCredentials[i].ClientDataHash = ""
|
||||
u.PasskeyCredentials[i].AuthenticatorData = ""
|
||||
u.PasskeyCredentials[i].PublicKeyAlg = 0
|
||||
u.PasskeyCredentials[i].AttestationObj = ""
|
||||
}
|
||||
}
|
||||
|
||||
// GetFrontendSidebarLinks converts the user's sidebar links from backend-style to frontend-style:
|
||||
func GetFrontendSidebarLinks(links []users.SidebarLink, showToolsInSidebar bool) []users.SidebarLink {
|
||||
if !users.SourceConfigLoaded() {
|
||||
return []users.SidebarLink{}
|
||||
}
|
||||
hasTools := false
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range links {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
if link.SourceName == "" {
|
||||
continue
|
||||
}
|
||||
source, ok := users.ResolveSourceKey(link.SourceName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if full, ok := config.Server.SourceMap[source.Path]; ok {
|
||||
if full.Config.ResolvedRules.IndexingDisabled && link.Category != "source-minimal" {
|
||||
link.Category = "source-alt"
|
||||
}
|
||||
}
|
||||
link.SourceName = source.Name
|
||||
} else if link.Category == "tool" && link.Target == "/tools" {
|
||||
hasTools = true
|
||||
}
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
if !hasTools && showToolsInSidebar {
|
||||
newLinks = append(newLinks, users.SidebarLink{
|
||||
Name: "Tools",
|
||||
Category: "tool",
|
||||
Target: "/tools",
|
||||
Icon: "build",
|
||||
})
|
||||
}
|
||||
return newLinks
|
||||
}
|
||||
|
||||
// normalizeLocale converts various locale formats (xx_xx, xx-xx, xxxx) to camelCase format (xxXX)
|
||||
@@ -179,12 +232,14 @@ func normalizeLocale(locale string) string {
|
||||
|
||||
// userDeleteHandler deletes a user by ID.
|
||||
// @Summary Delete a user by ID
|
||||
// @Description Deletes a user identified by their ID.
|
||||
// @Description Deletes a user identified by their ID. When the authenticated actor uses password login, they must send their current password in the X-Password header.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id query string true "User ID"
|
||||
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors"
|
||||
// @Success 200 "User deleted successfully"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users [delete]
|
||||
@@ -201,8 +256,13 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
return http.StatusForbidden, fmt.Errorf("cannot delete users without admin permissions")
|
||||
}
|
||||
|
||||
status, err := verifyActorPasswordForUserActions(r, d)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
// Delete the user
|
||||
err := store.Users.Delete(givenUserId)
|
||||
err = store.Users.Delete(givenUserId)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -211,13 +271,15 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
|
||||
// usersPostHandler creates a new user.
|
||||
// @Summary Create a new user
|
||||
// @Description Adds a new user to the system.
|
||||
// @Description Adds a new user to the system. When the authenticated actor uses password login, they must send their current password in the X-Password header.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors"
|
||||
// @Param data body users.User true "User data to create a new user"
|
||||
// @Success 201 {object} users.User "Created user"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users [post]
|
||||
func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -248,6 +310,11 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusBadRequest, errors.ErrEmptyPassword
|
||||
}
|
||||
|
||||
status, err := verifyActorPasswordForUserActions(r, d)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
err = storage.CreateUser(req.User, req.User.Permissions)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
@@ -257,25 +324,43 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusCreated, nil
|
||||
}
|
||||
|
||||
// passwordUpdateRequested reports whether the client intends to change the target user's password
|
||||
// (non-empty new password and "password" in the which list). This matches storage parseFields behavior.
|
||||
func passwordUpdateRequested(req *UserRequest) bool {
|
||||
if req.User.Password == "" {
|
||||
return false
|
||||
// nonAdminEditableFieldNameSet returns User.NonAdminEditable struct field names (e.g. "Locale", "Preview").
|
||||
func nonAdminEditableFieldNameSet() map[string]struct{} {
|
||||
m := make(map[string]struct{})
|
||||
t := reflect.TypeOf(users.NonAdminEditable{})
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
m[t.Field(i).Name] = struct{}{}
|
||||
}
|
||||
for _, w := range req.Which {
|
||||
if strings.EqualFold(w, "password") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return m
|
||||
}
|
||||
|
||||
// verifyActorPasswordForUserPasswordChange ensures the authenticated actor re-proves their password
|
||||
// when changing another user's or their own password via PUT. Requires URL-encoded X-Password (same as login).
|
||||
func verifyActorPasswordForUserPasswordChange(r *http.Request, d *requestContext, target *users.User) (int, error) {
|
||||
if d.user.LoginMethod != users.LoginMethodPassword || target.LoginMethod != users.LoginMethodPassword {
|
||||
return http.StatusForbidden, fmt.Errorf("password can only be changed when both accounts use password login")
|
||||
// userPutOnlyNonAdminEditableFields reports whether req.Which lists exclusively NonAdminEditable fields,
|
||||
// excluding Password. Empty which or which[0] == "all" means a broad update and returns false.
|
||||
func userPutOnlyNonAdminEditableFields(which []string) bool {
|
||||
if len(which) == 0 || strings.EqualFold(strings.TrimSpace(which[0]), "all") {
|
||||
return false
|
||||
}
|
||||
allowed := nonAdminEditableFieldNameSet()
|
||||
for _, w := range which {
|
||||
f := utils.CapitalizeFirst(strings.TrimSpace(w))
|
||||
if f == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(f, "Password") {
|
||||
return false
|
||||
}
|
||||
if _, ok := allowed[f]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// verifyActorPasswordForUserPut requires URL-encoded X-Password when the authenticated actor uses
|
||||
// password login. Callers should invoke this only when the update requires re-authentication.
|
||||
func verifyActorPasswordForUserActions(r *http.Request, d *requestContext) (int, error) {
|
||||
if d.user.LoginMethod != users.LoginMethodPassword {
|
||||
return 0, nil
|
||||
}
|
||||
encoded := r.Header.Get("X-Password")
|
||||
if encoded == "" {
|
||||
@@ -300,16 +385,16 @@ func verifyActorPasswordForUserPasswordChange(r *http.Request, d *requestContext
|
||||
|
||||
// userPutHandler updates an existing user's details.
|
||||
// @Summary Update a user's details
|
||||
// @Description Updates the details of a user identified by ID. When updating the target user's password the actor must send their current password in the X-Password header
|
||||
// @Description Updates the details of a user identified by ID. When the authenticated actor uses password login, they must send their current password in the X-Password header unless the update only touches NonAdminEditable profile fields (not password). Full updates (which empty or "all") or any admin-only field require confirmation.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Param id query string false "user ID to update"
|
||||
// @Param id query string false "usename to update"
|
||||
// @Param X-Password header string false "Actor's current password (URL-encoded); required when changing a password user's password"
|
||||
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors when updating password, using which=all, or any field outside NonAdminEditable"
|
||||
// @Param data body users.User true "User data to update"
|
||||
// @Success 204 "No Content - User updated successfully"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password for password change"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users [put]
|
||||
@@ -361,9 +446,9 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
if passwordUpdateRequested(&req) {
|
||||
if d.user.LoginMethod == users.LoginMethodPassword && !userPutOnlyNonAdminEditableFields(req.Which) {
|
||||
var status int
|
||||
status, err = verifyActorPasswordForUserPasswordChange(r, d, oldUser)
|
||||
status, err = verifyActorPasswordForUserActions(r, d)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ func errToStatus(err error) int {
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, libErrors.ErrInvalidRequestParams):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, libErrors.ErrIsDirectory):
|
||||
return http.StatusMethodNotAllowed
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
)
|
||||
|
||||
// @Summary Begin passkey MFA login
|
||||
// @Description Verifies the user's password and returns a WebAuthn assertion challenge for passkey MFA.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param username query string true "Username"
|
||||
// @Param X-Password header string true "URL-encoded password"
|
||||
// @Success 200 {object} map[string]interface{} "Passkey assertion options"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Router /api/auth/webauthn/begin-login [post]
|
||||
func beginPasskeyLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !auth.IsWebAuthnEnabled() {
|
||||
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
|
||||
}
|
||||
|
||||
if err := validateRequestOrigin(r); err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
username := r.URL.Query().Get("username")
|
||||
password := r.Header.Get("X-Password")
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
rpID := deriveRPID(r)
|
||||
svc := auth.GetWebAuthn()
|
||||
sessionID, assertion, err := svc.BeginMFALogin(username, password, rpID, store.Users)
|
||||
if err != nil {
|
||||
if err == errors.ErrPasskeyNoCredential {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"publicKey": assertion.Response,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return http.StatusOK, json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// @Summary Finish passkey MFA login
|
||||
// @Description Verifies the WebAuthn assertion and returns a JWT token.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param session_id query string true "Passkey session ID from begin-login"
|
||||
// @Success 200 {string} string "JWT token"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Router /api/auth/webauthn/finish-login [post]
|
||||
func finishPasskeyLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !auth.IsWebAuthnEnabled() {
|
||||
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
|
||||
}
|
||||
|
||||
if err := validateRequestOrigin(r); err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
sessionID := r.URL.Query().Get("session_id")
|
||||
if sessionID == "" {
|
||||
return http.StatusBadRequest, errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
|
||||
svc := auth.GetWebAuthn()
|
||||
user, err := svc.FinishMFALogin(sessionID, r, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
d.user = user
|
||||
return printToken(w, r, d.user)
|
||||
}
|
||||
|
||||
// @Summary Begin passkey registration
|
||||
// @Description Returns WebAuthn credential creation options for adding a new passkey.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "Passkey creation options"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Router /api/auth/webauthn/begin-register [post]
|
||||
func beginPasskeyRegistrationHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !auth.IsWebAuthnEnabled() {
|
||||
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
|
||||
}
|
||||
|
||||
if err := validateRequestOrigin(r); err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
rpID := deriveRPID(r)
|
||||
svc := auth.GetWebAuthn()
|
||||
sessionID, creation, err := svc.BeginRegistration(d.user, rpID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
"publicKey": creation.Response,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return http.StatusOK, json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// @Summary Finish passkey registration
|
||||
// @Description Verifies the attestation and stores the new passkey credential.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param session_id query string true "Registration session ID"
|
||||
// @Param name query string true "Display name for the passkey"
|
||||
// @Success 200 {object} map[string]string "Success"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Router /api/auth/webauthn/finish-register [post]
|
||||
func finishPasskeyRegistrationHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !auth.IsWebAuthnEnabled() {
|
||||
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
|
||||
}
|
||||
|
||||
if err := validateRequestOrigin(r); err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
sessionID := r.URL.Query().Get("session_id")
|
||||
credentialName := r.URL.Query().Get("name")
|
||||
if sessionID == "" {
|
||||
return http.StatusBadRequest, errors.ErrPasskeyInvalidSession
|
||||
}
|
||||
if credentialName == "" {
|
||||
credentialName = "Passkey"
|
||||
}
|
||||
|
||||
svc := auth.GetWebAuthn()
|
||||
if err := svc.FinishRegistration(d.user, sessionID, credentialName, r); err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
if err := store.Users.Update(d.user, false, "PasskeyCredentials"); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
// @Summary Delete a passkey credential
|
||||
// @Description Removes a passkey credential by its ID.
|
||||
// @Tags Auth
|
||||
// @Param id path string true "Base64-encoded credential ID"
|
||||
// @Success 200 {object} map[string]string "Success"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Router /api/auth/webauthn/{id} [delete]
|
||||
func deletePasskeyCredentialHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !auth.IsWebAuthnEnabled() {
|
||||
return http.StatusForbidden, errors.ErrPasskeyNotEnabled
|
||||
}
|
||||
|
||||
credentialID := r.PathValue("id")
|
||||
if credentialID == "" {
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
svc := auth.GetWebAuthn()
|
||||
if err := svc.DeleteCredential(d.user, credentialID); err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
|
||||
if err := store.Users.Update(d.user, false, "PasskeyCredentials"); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
func deriveRPID(r *http.Request) string {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
if i := strings.LastIndex(host, ":"); i != -1 {
|
||||
host = host[:i]
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// validateRequestOrigin validates that the request's origin is in the configured whitelist.
|
||||
// Returns an error if the origin is not allowed.
|
||||
func validateRequestOrigin(r *http.Request) error {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
} else if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = strings.ToLower(proto)
|
||||
}
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
origin := fmt.Sprintf("%s://%s", scheme, host)
|
||||
|
||||
svc := auth.GetWebAuthn()
|
||||
if svc != nil {
|
||||
if !svc.ValidateOrigin(origin) {
|
||||
return fmt.Errorf("origin %s is not in the configured whitelist", origin)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -84,6 +84,7 @@ func (ffs *filteredFileSystem) getFileInfo(requestPath string, expand bool) (*it
|
||||
Source: ffs.source,
|
||||
Expand: expand,
|
||||
ShowHidden: ffs.user.ShowHidden,
|
||||
HideFileExt: ffs.user.HideFileExt,
|
||||
SkipExtendedAttrs: true,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, ffs.user, store.Share)
|
||||
@@ -382,6 +383,7 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
Path: requestPath,
|
||||
Source: source,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
HideFileExt: d.user.HideFileExt,
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
|
||||
+49
-49
@@ -12,9 +12,9 @@ import (
|
||||
|
||||
storm "github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
commonerrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
@@ -41,7 +41,7 @@ func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
filepath.Join(dockerPath, "data"),
|
||||
filepath.Join(source2Path, "shared"),
|
||||
}
|
||||
|
||||
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -50,12 +50,12 @@ func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
|
||||
// Create test files
|
||||
testFiles := map[string]string{
|
||||
filepath.Join(source1Path, "public", "readme.txt"): "public content",
|
||||
filepath.Join(source1Path, "private", "secret.txt"): "private content",
|
||||
filepath.Join(source1Path, "viewable-only", "data.txt"): "viewable content",
|
||||
filepath.Join(source1Path, "not-viewable", "hidden.txt"): "hidden content",
|
||||
filepath.Join(dockerPath, "data", "config.yml"): "docker config",
|
||||
filepath.Join(source2Path, "shared", "document.txt"): "shared content",
|
||||
filepath.Join(source1Path, "public", "readme.txt"): "public content",
|
||||
filepath.Join(source1Path, "private", "secret.txt"): "private content",
|
||||
filepath.Join(source1Path, "viewable-only", "data.txt"): "viewable content",
|
||||
filepath.Join(source1Path, "not-viewable", "hidden.txt"): "hidden content",
|
||||
filepath.Join(dockerPath, "data", "config.yml"): "docker config",
|
||||
filepath.Join(source2Path, "shared", "document.txt"): "shared content",
|
||||
}
|
||||
|
||||
for path, content := range testFiles {
|
||||
@@ -74,18 +74,18 @@ func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Clear any previous access rules
|
||||
store.Access.AllRules = make(access.SourceRuleMap)
|
||||
store.Access.Groups = make(access.GroupMap)
|
||||
|
||||
// Set cache directory for index database
|
||||
settings.Config.Server.CacheDir = tempDir
|
||||
|
||||
// Setup config with sources - update both the local config var AND the global settings.Config
|
||||
config.Server.CacheDir = tempDir
|
||||
|
||||
// Setup config with sources - update both the local config var AND the global config
|
||||
config = &settings.Settings{
|
||||
Server: settings.Server{
|
||||
BaseURL: "/", // Use / for tests to avoid prefix mismatch
|
||||
BaseURL: "/", // Use / for tests to avoid prefix mismatch
|
||||
CacheDir: tempDir,
|
||||
SourceMap: map[string]*settings.Source{
|
||||
source1Path: {
|
||||
@@ -109,8 +109,8 @@ func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// CRITICAL: Also update the global settings.Config so access.Permitted can find sources
|
||||
|
||||
// CRITICAL: Also update the global config so access.Permitted can find sources
|
||||
settings.Config.Server.SourceMap = config.Server.SourceMap
|
||||
settings.Config.Server.NameToSource = config.Server.NameToSource
|
||||
|
||||
@@ -136,11 +136,11 @@ func setupWebDAVTestEnv(t *testing.T) (string, string) {
|
||||
// mockCheckPermissions mocks the CheckPermissions function to bypass index lookups
|
||||
func mockCheckPermissions(t *testing.T, source1Path, source2Path string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
// Store original function
|
||||
originalCheckPermissions := files.CheckPermissionsFunc
|
||||
t.Cleanup(func() { files.CheckPermissionsFunc = originalCheckPermissions })
|
||||
|
||||
|
||||
// Mock the function
|
||||
files.CheckPermissionsFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
// Get the source path
|
||||
@@ -152,7 +152,7 @@ func mockCheckPermissions(t *testing.T, source1Path, source2Path string) {
|
||||
} else {
|
||||
return "", "", fmt.Errorf("unknown source: %s", opts.Source)
|
||||
}
|
||||
|
||||
|
||||
// Get user scope for this source - match by source PATH, not name
|
||||
userScope := "/"
|
||||
hasScope := false
|
||||
@@ -163,26 +163,26 @@ func mockCheckPermissions(t *testing.T, source1Path, source2Path string) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If user has no scope for this source, deny access
|
||||
if !hasScope && len(user.Scopes) > 0 {
|
||||
return "", "", fmt.Errorf("user has no access to source: %s", opts.Source)
|
||||
}
|
||||
|
||||
|
||||
// Sanitize path
|
||||
safePath, err := utils.SanitizeUserPath(opts.Path)
|
||||
if err != nil {
|
||||
return "", "", commonerrors.ErrAccessDenied
|
||||
}
|
||||
|
||||
|
||||
// Combine scope + sanitized path
|
||||
indexPath := utils.JoinPathAsUnix(userScope, safePath)
|
||||
|
||||
|
||||
// Check access control
|
||||
if access != nil && !access.Permitted(sourcePath, indexPath, user.Username) {
|
||||
return "", "", commonerrors.ErrAccessDenied
|
||||
}
|
||||
|
||||
|
||||
return indexPath, userScope, nil
|
||||
}
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func mockWebDAVIndexing(t *testing.T, source1Path, source2Path string) {
|
||||
} else {
|
||||
return nil, fmt.Errorf("unknown source: %s", opts.Source)
|
||||
}
|
||||
|
||||
|
||||
// Simulate access control
|
||||
if access != nil && user != nil {
|
||||
// Simple scope check based on user's configured scopes (which use source PATH)
|
||||
@@ -214,7 +214,7 @@ func mockWebDAVIndexing(t *testing.T, source1Path, source2Path string) {
|
||||
// Check if path is within user's scope
|
||||
userScope := scope.Scope
|
||||
fullPath := utils.JoinPathAsUnix(userScope, opts.Path)
|
||||
|
||||
|
||||
// Check if user has permission using the access storage
|
||||
if !access.Permitted(sourcePath, fullPath, user.Username) {
|
||||
return nil, commonerrors.ErrAccessDenied
|
||||
@@ -229,12 +229,12 @@ func mockWebDAVIndexing(t *testing.T, source1Path, source2Path string) {
|
||||
|
||||
// Simulate indexing rules
|
||||
path := opts.Path
|
||||
|
||||
|
||||
// Paths containing "not-viewable" are not indexed and not viewable
|
||||
if strings.Contains(path, "not-viewable") {
|
||||
return nil, commonerrors.ErrNotViewable
|
||||
}
|
||||
|
||||
|
||||
// Paths containing "viewable-only" are not indexed but viewable
|
||||
if strings.Contains(path, "viewable-only") {
|
||||
return nil, commonerrors.ErrNotIndexed
|
||||
@@ -333,12 +333,12 @@ func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *users.User
|
||||
source string
|
||||
path string
|
||||
expectedStatus int
|
||||
shouldHaveContent bool
|
||||
name string
|
||||
user *users.User
|
||||
source string
|
||||
path string
|
||||
expectedStatus int
|
||||
shouldHaveContent bool
|
||||
}{
|
||||
{
|
||||
name: "Admin can access root of source1",
|
||||
@@ -353,7 +353,7 @@ func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
|
||||
user: scopedUser,
|
||||
source: "source1",
|
||||
path: "/",
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
|
||||
shouldHaveContent: false, // Empty because _docker is not viewable
|
||||
},
|
||||
{
|
||||
@@ -390,19 +390,19 @@ func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
|
||||
req := httptest.NewRequest("PROPFIND", "/dav/"+tc.source+tc.path, nil)
|
||||
req.SetPathValue("source", tc.source)
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: tc.user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
|
||||
@@ -529,7 +529,7 @@ func TestWebDAV_WriteOperations(t *testing.T) {
|
||||
name: "Scoped user can write in their scope",
|
||||
user: scopedUser,
|
||||
method: http.MethodPut,
|
||||
path: "/scopedfile.txt", // Path relative to scope (/public)
|
||||
path: "/scopedfile.txt", // Path relative to scope (/public)
|
||||
expectedStatus: http.StatusCreated, // 201 for new file
|
||||
},
|
||||
{
|
||||
@@ -551,19 +551,19 @@ func TestWebDAV_WriteOperations(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, "/dav/source1"+tc.path, body)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: tc.user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
|
||||
@@ -611,7 +611,7 @@ func TestWebDAV_AccessControl(t *testing.T) {
|
||||
|
||||
// Initialize index
|
||||
initTestIndex(t, "source1", source1Path)
|
||||
|
||||
|
||||
// Create access rules by using the internal structure
|
||||
// User1 can access /private, user2 cannot
|
||||
// Set up a deny rule for user2 at /private
|
||||
@@ -621,7 +621,7 @@ func TestWebDAV_AccessControl(t *testing.T) {
|
||||
Users: access.StringSet{"user2": struct{}{}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// Add the rule to the access storage
|
||||
if store.Access.AllRules == nil {
|
||||
store.Access.AllRules = make(access.SourceRuleMap)
|
||||
@@ -668,19 +668,19 @@ func TestWebDAV_AccessControl(t *testing.T) {
|
||||
req := httptest.NewRequest("PROPFIND", "/dav/source1"+tc.path, nil)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: tc.user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
|
||||
@@ -775,19 +775,19 @@ func TestWebDAV_IndexingStates(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, "/dav/source1"+tc.path, body)
|
||||
req.SetPathValue("source", "source1")
|
||||
req.SetPathValue("path", tc.path)
|
||||
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx := &requestContext{user: user}
|
||||
|
||||
status, err := webDAVHandler(w, req, ctx)
|
||||
|
||||
|
||||
// If handler returned an error status, use that instead of response code
|
||||
if status != 0 && status != http.StatusOK {
|
||||
if w.Code == 0 || w.Code == http.StatusOK {
|
||||
w.Code = status
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check the actual HTTP response status code from ResponseWriter
|
||||
if w.Code != tc.expectedStatus {
|
||||
t.Errorf("%s: expected status %d, got %d (err: %v)", tc.description, tc.expectedStatus, w.Code, err)
|
||||
|
||||
+11
-21
@@ -2,6 +2,7 @@ package icons
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -29,7 +30,6 @@ const (
|
||||
type iconSize struct {
|
||||
size int
|
||||
name string
|
||||
env *string
|
||||
}
|
||||
|
||||
// determineSourceType determines which type of favicon source we have
|
||||
@@ -153,13 +153,13 @@ func handleRasterFavicon(faviconPath string) ([]byte, error) {
|
||||
func getIconSizesToGenerate() []iconSize {
|
||||
return []iconSize{
|
||||
// Browser favicon (32x32 PNG for Safari/older browsers)
|
||||
{32, "favicon-32x32.png", nil},
|
||||
{32, "favicon-32x32.png"},
|
||||
// PWA icons (PNG required for Apple PWA compatibility)
|
||||
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
|
||||
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
|
||||
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
|
||||
{192, "pwa-icon-192.png"},
|
||||
{256, "pwa-icon-256.png"}, // Also serves as Windows tile
|
||||
{512, "pwa-icon-512.png"},
|
||||
// Platform-specific icons
|
||||
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
|
||||
{180, "apple-touch-icon.png"}, // iOS home screen (required for Apple)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,10 +181,6 @@ func generateIconSizes(sourceData []byte) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update environment variable if specified
|
||||
if icon.env != nil {
|
||||
*icon.env = filepath.Join("icons", icon.name)
|
||||
}
|
||||
generatedCount++
|
||||
}
|
||||
|
||||
@@ -199,7 +195,7 @@ func generateIconSizes(sourceData []byte) error {
|
||||
|
||||
// generateSingleIcon generates a single icon file at the specified size
|
||||
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
|
||||
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
|
||||
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputPath)
|
||||
@@ -210,6 +206,7 @@ func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon
|
||||
|
||||
// Resize image using the preview service's Resize method
|
||||
err = previewService.Resize(
|
||||
context.Background(),
|
||||
bytes.NewReader(sourceData),
|
||||
outFile,
|
||||
preview.ResizeOptions{
|
||||
@@ -233,18 +230,11 @@ func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon
|
||||
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
|
||||
func GeneratePWAIcons() error {
|
||||
logger.Info("Generating PWA icons...")
|
||||
// Set PWA icons directory in cache
|
||||
settings.Env.PWAIconsDir = filepath.Join(settings.Config.Server.CacheDir, "icons")
|
||||
|
||||
// Initialize with default paths (all icons will be generated)
|
||||
settings.Env.PWAIcon192 = "icons/pwa-icon-192.png"
|
||||
settings.Env.PWAIcon256 = "icons/pwa-icon-256.png"
|
||||
settings.Env.PWAIcon512 = "icons/pwa-icon-512.png"
|
||||
|
||||
cacheDir := settings.PWAIconsCacheDir()
|
||||
// Create icons directory with configured permissions
|
||||
// Note: Parent cache directory should already exist from testCacheDirSpeed()
|
||||
if err := os.MkdirAll(settings.Env.PWAIconsDir, fileutils.PermDir); err != nil {
|
||||
logger.Warningf("Failed to create PWA icons directory %s: %v", settings.Env.PWAIconsDir, err)
|
||||
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
|
||||
logger.Warningf("Failed to create PWA icons directory %s: %v", cacheDir, err)
|
||||
}
|
||||
// Load and prepare source data as PNG
|
||||
sourceData, err := loadFaviconSource()
|
||||
|
||||
+13
-19
@@ -1,9 +1,6 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
|
||||
@@ -11,6 +8,8 @@ import (
|
||||
type PWAManifest struct {
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"short_name"`
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
Icons []PWAIcon `json:"icons"`
|
||||
StartURL string `json:"start_url"`
|
||||
Display string `json:"display"`
|
||||
@@ -36,10 +35,15 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
|
||||
if len(name) > 12 {
|
||||
shortName = name[:12]
|
||||
}
|
||||
if name == "FileBrowser Quantum" {
|
||||
shortName = "FBQ"
|
||||
}
|
||||
|
||||
return PWAManifest{
|
||||
Name: name,
|
||||
ShortName: shortName,
|
||||
ID: baseURL,
|
||||
Scope: baseURL,
|
||||
StartURL: baseURL,
|
||||
Display: "standalone",
|
||||
BackgroundColor: "#ffffff",
|
||||
@@ -68,7 +72,9 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
|
||||
}
|
||||
}
|
||||
|
||||
// InitializePWAManifest generates and caches the PWA manifest at startup
|
||||
// InitializePWAManifest caches the PWA manifest at startup. Icon URLs always point at
|
||||
// PNGs under public/static/icons/, which GeneratePWAIcons produces from the configured
|
||||
// favicon (custom SVG uses a raster sidecar as the raster source).
|
||||
func InitializePWAManifest() {
|
||||
config := &settings.Config
|
||||
staticURL := config.Server.BaseURL + "public/static"
|
||||
@@ -76,21 +82,9 @@ func InitializePWAManifest() {
|
||||
description := config.Frontend.Description
|
||||
defaultThemeColor := "#455a64"
|
||||
|
||||
// Determine PWA icon URLs based on custom favicon settings
|
||||
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
|
||||
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
|
||||
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
|
||||
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
|
||||
favicon := staticURL + "/favicon"
|
||||
pwaIcon192 = favicon
|
||||
pwaIcon256 = favicon
|
||||
pwaIcon512 = favicon
|
||||
} else if settings.Env.FaviconIsCustom {
|
||||
pwaIcon192 = staticURL + "/icons/pwa-icon-192.png"
|
||||
pwaIcon256 = staticURL + "/icons/pwa-icon-256.png"
|
||||
pwaIcon512 = staticURL + "/icons/pwa-icon-512.png"
|
||||
}
|
||||
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
|
||||
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
|
||||
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
|
||||
|
||||
CachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
)
|
||||
|
||||
const maxManifestTextLen = 256
|
||||
|
||||
func isValidShareHash(hash string) bool {
|
||||
if len(hash) < 8 || len(hash) > 64 {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(hash, "..") || strings.Contains(hash, "/") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ShareRootFromStartURL validates a share PWA start URL and returns the share root
|
||||
// used for manifest scope/id plus the normalized start URL.
|
||||
func ShareRootFromStartURL(baseURL, startURL string) (shareRoot, normalizedStart string, ok bool) {
|
||||
if startURL == "" || !strings.HasPrefix(startURL, baseURL) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
rel := strings.TrimPrefix(startURL, baseURL)
|
||||
if !strings.HasPrefix(rel, "public/share/") {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(rel, "public/share/")
|
||||
hash, subPath, _ := strings.Cut(rest, "/")
|
||||
if !isValidShareHash(hash) {
|
||||
return "", "", false
|
||||
}
|
||||
if subPath != "" {
|
||||
if _, err := utils.SanitizeUserPath(subPath); err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
shareRoot = baseURL + "public/share/" + hash + "/"
|
||||
normalizedStart = startURL
|
||||
if !strings.HasPrefix(normalizedStart, shareRoot) && normalizedStart != strings.TrimSuffix(shareRoot, "/") {
|
||||
return "", "", false
|
||||
}
|
||||
if normalizedStart == baseURL+"public/share/"+hash {
|
||||
normalizedStart = shareRoot
|
||||
}
|
||||
|
||||
return shareRoot, normalizedStart, true
|
||||
}
|
||||
|
||||
// ShareHashFromHTTPPath extracts a share hash from common FileBrowser share routes.
|
||||
func ShareHashFromHTTPPath(path, baseURL string) string {
|
||||
prefixes := []string{
|
||||
strings.TrimSuffix(baseURL, "/") + "/public/share/",
|
||||
"/public/share/",
|
||||
"/share/",
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
hash, _, _ := strings.Cut(rest, "/")
|
||||
if isValidShareHash(hash) {
|
||||
return hash
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeManifestText(value string) string {
|
||||
if len(value) > maxManifestTextLen {
|
||||
return value[:maxManifestTextLen]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ManifestForShare returns a manifest scoped to a single public share link.
|
||||
func ManifestForShare(baseURL, startURL, name, description string) (PWAManifest, bool) {
|
||||
shareRoot, normalizedStart, ok := ShareRootFromStartURL(baseURL, startURL)
|
||||
if !ok {
|
||||
return PWAManifest{}, false
|
||||
}
|
||||
|
||||
manifest := CachedManifest
|
||||
manifest.StartURL = normalizedStart
|
||||
manifest.Scope = shareRoot
|
||||
manifest.ID = shareRoot
|
||||
|
||||
if name = sanitizeManifestText(strings.TrimSpace(name)); name != "" {
|
||||
manifest.Name = name
|
||||
shortName := name
|
||||
if len(shortName) > 12 {
|
||||
shortName = shortName[:12]
|
||||
}
|
||||
manifest.ShortName = shortName
|
||||
}
|
||||
if description = sanitizeManifestText(strings.TrimSpace(description)); description != "" {
|
||||
manifest.Description = description
|
||||
}
|
||||
|
||||
return manifest, true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package icons
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShareRootFromStartURL(t *testing.T) {
|
||||
baseURL := "/testing/"
|
||||
startURL := "/testing/public/share/abc123XYZ-_/"
|
||||
shareRoot, normalizedStart, ok := ShareRootFromStartURL(baseURL, startURL)
|
||||
if !ok {
|
||||
t.Fatal("expected valid share start URL")
|
||||
}
|
||||
if shareRoot != "/testing/public/share/abc123XYZ-_/" {
|
||||
t.Fatalf("unexpected share root: %q", shareRoot)
|
||||
}
|
||||
if normalizedStart != startURL {
|
||||
t.Fatalf("unexpected normalized start: %q", normalizedStart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareRootFromStartURLRejectsTraversal(t *testing.T) {
|
||||
baseURL := "/testing/"
|
||||
startURL := "/testing/public/share/abc123XYZ-_/../secret/"
|
||||
_, _, ok := ShareRootFromStartURL(baseURL, startURL)
|
||||
if ok {
|
||||
t.Fatal("expected traversal path to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareHashFromHTTPPath(t *testing.T) {
|
||||
baseURL := "/testing/"
|
||||
paths := []string{
|
||||
"/testing/public/share/hash1234567890abcdef/",
|
||||
"/public/share/hash1234567890abcdef/files",
|
||||
"/share/hash1234567890abcdef/",
|
||||
}
|
||||
for _, path := range paths {
|
||||
hash := ShareHashFromHTTPPath(path, baseURL)
|
||||
if hash != "hash1234567890abcdef" {
|
||||
t.Fatalf("path %q: got hash %q", path, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareHashFromHTTPPathRejectsEmbeddedPrefix(t *testing.T) {
|
||||
baseURL := "/testing/"
|
||||
path := "/evil/route/share/hash1234567890abcdef/"
|
||||
hash := ShareHashFromHTTPPath(path, baseURL)
|
||||
if hash != "" {
|
||||
t.Fatalf("expected empty hash for non-prefixed path, got %q", hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestForShare(t *testing.T) {
|
||||
CachedManifest = PWAManifest{
|
||||
Name: "FileBrowser Quantum",
|
||||
ShortName: "FBQ",
|
||||
StartURL: "/testing/",
|
||||
Scope: "/testing/",
|
||||
ID: "/testing/",
|
||||
}
|
||||
|
||||
manifest, ok := ManifestForShare(
|
||||
"/testing/",
|
||||
"/testing/public/share/hash1234567890abcdef/",
|
||||
"My Share",
|
||||
"Shared files",
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected share manifest")
|
||||
}
|
||||
if manifest.StartURL != "/testing/public/share/hash1234567890abcdef/" {
|
||||
t.Fatalf("unexpected start_url: %q", manifest.StartURL)
|
||||
}
|
||||
if manifest.Scope != "/testing/public/share/hash1234567890abcdef/" {
|
||||
t.Fatalf("unexpected scope: %q", manifest.Scope)
|
||||
}
|
||||
if manifest.Name != "My Share" {
|
||||
t.Fatalf("unexpected name: %q", manifest.Name)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,7 @@ func setupConditionalTestIndex(conditionals settings.ConditionalFilter) *Index {
|
||||
Name: "test",
|
||||
Path: "/test/path",
|
||||
Config: settings.SourceConfig{
|
||||
DisableIndexing: false,
|
||||
Conditionals: conditionals,
|
||||
Conditionals: conditionals,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -26,11 +26,9 @@ func setupTestIndexForDeletion(t *testing.T) *Index {
|
||||
// Create index with mock data
|
||||
idx := &Index{
|
||||
Source: settings.Source{
|
||||
Name: "test_delete",
|
||||
Path: "/mock/path",
|
||||
Config: settings.SourceConfig{
|
||||
DisableIndexing: false,
|
||||
},
|
||||
Name: "test_delete",
|
||||
Path: "/mock/path",
|
||||
Config: settings.SourceConfig{},
|
||||
},
|
||||
db: indexDB,
|
||||
mock: true, // Enable mock mode
|
||||
|
||||
Loaded 100 of 381 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user