Compare commits

...
41 Commits
Author SHA1 Message Date
Graham Steffaniak 6a997d4605 updated with rate limiting (#2484) 2026-05-22 10:28:19 -05:00
Graham Steffaniakandcodacy-production[bot] 2633768d9f fix panic on shutdown or folder deletion (#2467)
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-20 14:20:26 -05:00
Graham Steffaniak 4bca80a156 Fix allow listing override (#2471) 2026-05-20 14:20:05 -05:00
Graham Steffaniak fc929970b3 Fix onlyoffice rewrite and remove CSP (#2466) 2026-05-19 18:26:41 -05:00
Graham Steffaniak 12c71d38a9 Read only source config (#2464) 2026-05-19 11:47:53 -05:00
Graham Steffaniak 641e4c5397 updated readme 2026-05-18 17:15:49 -05:00
Graham Steffaniak 587cd62da6 updated readme 2026-05-18 17:03:20 -05:00
Graham Steffaniak ea19302c25 Merge branch 'dev/v1.4.2' of github.com:gtsteffaniak/filebrowser into dev/v1.4.2 2026-05-18 16:54:14 -05:00
d4dd0c70f4 Update linting for codacy (#2460)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 16:53:35 -05:00
Graham Steffaniak 1babfa03bc Merge branch 'dev/v1.4.2' of github.com:gtsteffaniak/filebrowser into dev/v1.4.2 2026-05-18 16:45:13 -05:00
daa48c3c31 Add rate limiting for auth (#2458)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 16:00:40 -05:00
Graham Steffaniak a9e4fc27b0 Merge branch 'dev/v1.4.2' of github.com:gtsteffaniak/filebrowser into dev/v1.4.2 2026-05-18 14:52:00 -05:00
1715577d99 Merge main changes to v1.4.2 (#2457)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 14:51:24 -05:00
Graham Steffaniak 5eb4dc7f7c Merge branch 'main' into dev/v1.4.2 2026-05-18 14:43:49 -05:00
Graham Steffaniak 4248849bb6 Merge branch 'main' of github.com:gtsteffaniak/filebrowser 2026-05-18 14:43:36 -05:00
687dc01293 Update en.json (#2456)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 14:43:31 -05:00
bfde65a41e Update nl.json (#2448)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 14:43:18 -05:00
Graham Steffaniak c5eeb308fa Merge branch 'main' into dev/v1.4.2 2026-05-18 14:42:47 -05:00
Graham Steffaniak 12cd81340e Merge branch 'staging/v1.3.3' 2026-05-18 14:42:26 -05:00
2b532c368d fix logout and closing of profile settings in shares + other fixes (#2445)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-05-18 14:36:26 -05:00
Graham Steffaniak 28e9b81e43 Public patch transversal fix (#2444) 2026-05-18 14:35:14 -05:00
Graham Steffaniak 6d4bffd407 updated with static content handler change (#2439) 2026-05-18 14:33:49 -05:00
Kevin B. 5e49e1ecdd fix path slash issue on windows (#2451) 2026-05-18 09:45:25 -05:00
Graham Steffaniak 1aba10b87f updated share hash middleware (#2443) 2026-05-16 13:45:32 -05:00
Graham Steffaniak 15f484fba5 updated with new tests (#2429) 2026-05-15 10:10:38 -05:00
Graham Steffaniak d4eb051294 added codacy changes (#2430) 2026-05-15 10:10:25 -05:00
1802e12811 updated to fix critical bug (#2424) (#2427)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
2026-05-14 10:47:26 -05:00
Graham Steffaniak 5597b28f10 update anonymous user access 2026-05-13 20:03:23 -05:00
0f215333ee Beta/v1.4.0 (#2421)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
2026-05-13 14:56:35 -05:00
Graham Steffaniak 6d81d1973c Beta/v1.3.10 (#2395) 2026-05-08 14:06:45 -05:00
Graham Steffaniak 869b640a99 Update auth.go (#2381) 2026-05-04 13:32:54 -05:00
Stephan Paternotte 3b8d179774 Update nl.json (#2383) 2026-05-04 12:26:28 -05:00
Graham Steffaniak 569314cdf4 Beta/v1.3.9 (#2377) 2026-05-01 16:11:48 -05:00
Graham Steffaniak 6bfc397419 Fix inline svg attack (#2376) 2026-05-01 13:49:55 -05:00
Graham Steffaniak 112740bdd4 security bugfix (#2374) 2026-05-01 13:38:44 -05:00
Graham Steffaniak 9117fb2b5f fix context menu (#2373) 2026-05-01 10:05:04 -05:00
Graham Steffaniak 668efec4d7 added new screenshots (#2371) 2026-04-30 15:50:36 -05:00
Graham Steffaniak a5c81c8e2e Beta/v1.3.8 (#2363) 2026-04-30 11:55:14 -05:00
Graham Steffaniak 88aa337696 Beta/v1.3.7 (#2350) 2026-04-27 16:32:13 -05:00
9b48ce414e Beta/v1.3.6 (#2324)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-20 17:00:10 -05:00
0723edf660 Beta/v1.3.5 (#2316)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-18 18:24:10 -05:00
250 changed files with 18791 additions and 5125 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
{
"name": "filebrowser",
"image": "mcr.microsoft.com/devcontainers/go:1.25-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"containerEnv": {
"CGO_ENABLED": "1",
"FILEBROWSER_DEVMODE": "true"
},
"forwardPorts": [80],
"portsAttributes": {
"80": {
"label": "filebrowser app",
"onAutoForward": "notify"
}
},
"postCreateCommand": "make setup",
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
]
}
}
}
+3
View File
@@ -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
+137
View File
@@ -2,11 +2,148 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.4.2
**Security**:
- [Moderate] Add Rate Limiting on Authentication Endpoint Enables Brute Force Attacks (GHSA-r4v7-6wcg-ghj5)
- [Critical] Path traversal in public share PATCH allows file ops outside shared directory -- thanks @Revanth011 and @fg0x0 (GHSA-qqqm-5547-774x)
**New Features**:
- read-only source configuration via `source.config.readOnly: true` (#2438)
**Notes**:
- auth rate limiting can be disabled via `auth.disableRateLimit`
- updated share hash middleware (#2443)
- updated source info popup to include private and readOnly properties
**BugFixes**:
- Logout from share page now redirects to the share instead of `/Login` again. (#2245)
- `This location cannot be reached` error when navigating with FileTree in shares. (#2245)
- Fix FileTree rename and move actions in previews. (#2245)
- Delete prompt not showing date and thumbnails in some previews. (#2245)
- fix path slash issue on windows (#2451) (#2433) (#2419)
- Always force url rewrite for onlyoffice internal URL. Fixes Error saving with OnlyOffice (#2450)
- Overriding a Deny with an Allow not working (#2405)
## v1.4.1
**Security**:
- Fix critical: unauthenticated user can view source info
## v1.4.0
**New Features**:
- Add Passkey (WebAuthn) support for passwordless authentication (#2287) (#2379) -- thanks @juansoler
- Lyrics support for audio files (#2360):
- Updated UI for audio previews!
- The lyrics and the player gets syncronized in UI with the audio player.
- Supports embedded lyrics (ID3v2 USLT, flac, ogg, clyr) and sidecar `.lrc` files with the same name as the audio file.
- The `Q` shortcut in audio files will toggle the new panel visibility.
- `E` shortcut to change between tabs of the panel if it's open.
- Api log filtering support via `apiFilter` option for logging.
- regex filter that excludes matching full api paths from being logged. (eg. `/user\?id\=self`) Defaults to `^/health|^/favicon.ico|^/static|^/public/static`
- Add config option to disable /health endpoint logging (#2291)
- More shortcuts (#2300)
- `CTRL+F1` switch to normal view.
- `CTRL+F2` switch to gallery view.
- `CTRL+F3` switch to list view.
- `Double click` on empty space to select all items.
- Option to hide certain files in UI by extension (#2403) (#2277)
- Additional search features
- wildcard search similar to regex, but limited to sqlite GLOB syntax (#769)
- advanced search tool that shows all results in a listing thats sortable and actionable like a normal listing (#1051)
- advanced search supports multiple custom scopes per search
- multiple search terms with AND or OR logic support
**Notes**:
- Enhanced indexing scheduler which doesn't wake the disk as often.
- New API route `media/lyrics` used to fetch and parse lyrics (embedded or from `.lrc` sidecar) (#2360)
- Swiping down gesture in fullscreen videos exit fullscreen instead of close preview.
- Improved styles for path selection and tables
- Improved style of drag and drop into listing view (#2407)
- Edit Sidebar links has new "show tools in sidebar" toggle and all users have this enabled by default. can be disabled via for new users `userDefaults.sidebar.showTools: false`
- Update user defaults ordering (#1140)
- Save view modes and sizes into local storage instead of db (#2301)
**BugFixes**:
- Blue overlay when using gestures in video files on mobile (#2360)
- Playback queue wasn't updating when changing of folder (#2360)
- Added missing `UserDefaults.listing.showCopyPath` option to config file (#2364)
- Delete prompt thumbnail didn't worked when using it with the `delete` shortcut. (#2407)
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
- Drag and drop style state was stuck until page reload (#2407)
- Navigate close settings shows "something went wrong" (#2047)
## v1.3.10
**Notes**:
- When indexing is disabled for a source, the usage will always reported as partition size.
- removed deprecated `source.config.disableIndexing`, see [rules](https://filebrowserquantum.com/en/docs/advanced/source-configuration/conditional-rules/#disable-indexing)
**BugFixes**:
- Disable index option not working (#2385)
## v1.3.9
**Security**:
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
**BugFixes**:
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
## v1.3.8
**BugFixes**:
- Quick download icon style after icon change.
- Missing error popup for resource creatoin actions (upload/create)
- EnforcedOtp login failure until restart (#2330)
- Thumbnails for Folders only display sporadically (#2353)
- Unwanted user scope change for users with non-default scopes (#2347)
- Fix sidebar source info totals (#2321) (#2322) (#982)
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
## v1.3.7
**Notes**:
- creating/deleting password-based user requires reauthentication (#2112)
**BugFixes**:
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
- No Loginfields shown if password authentification is set to false (#2331)
## v1.3.6
**BugFixes**:
- file watcher not working for files and not respecting line count (#2314)
- "source" type link shows up multiple times when adding sidebar links if user has multiple sources.
- JWT ES256 results in error (#2188)
- single file shares not loading editor text content (#2122)
## v1.3.5
**Notes**:
- any password change or admin user property change for a password user requires reauthentication (#2112)
**BugFixes**:
- PWA icon fixes (#2292)
- deny-rule'd folders visible in directory listings (regression from v1.2.4-stable) (#2295)
- OTP and password requirement fixes (#2112) (#2263)
- CLI not picking up config properly
- Storage usage numbers on first load adjusted.
- Some items in userDefaults are not setting defaults for new users properly #2278
- Fix copy to clipboard and simplify code (#2281) (#2274)
- Fix search shortcut `/` and pdf previews (#2307)
- Special permissions bit not set when declared in createDirectoryPermission (#2283)
- Execute permission altered after editing sh script with FB Quantum (#2309)
- Source is red but working (#2289)
## v1.3.4
**New Features**:
- Epub placementg url anchors to bookmark a specific location on the doc.
- Add Requirement for Current Password When Changing Account Password (#2112)
- Copy file path to clipborad through right click (#2204)
**Notes**:
- user scope editing has path picker and filesystem validation
+1 -1
View File
@@ -1,7 +1,7 @@
<div align="center">
[![Go Report Card](https://goreportcard.com/badge/github.com/gtsteffaniak/filebrowser/backend)](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/0b548794f2ac4871a0cf7aa9ecab049f)](https://app.codacy.com/gh/gtsteffaniak/filebrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
[![latest version](https://img.shields.io/github/v/release/gtsteffaniak/filebrowser)](https://github.com/gtsteffaniak/filebrowser/releases)
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
+5 -2
View File
@@ -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
+1
View File
@@ -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
+2 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -1,6 +1,7 @@
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
+2 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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,
},
],
});
+5 -2
View File
@@ -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 -1
View File
@@ -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"
+108 -1
View File
@@ -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
@@ -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)
+177 -41
View File
@@ -1,11 +1,13 @@
package files
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -46,7 +48,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 {
@@ -143,7 +145,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 +155,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 +198,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)
}
}
@@ -204,9 +228,10 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
indexPath, userScope, err := CheckPermissions(opts, access, user)
if err != nil {
return response, err
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,25 +244,28 @@ 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
}
}
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 share != nil && user.Permissions.Share && opts.ShowSharedAttr {
for i := range response.Files {
file := &response.Files[i]
@@ -251,11 +279,17 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
}
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 +341,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 +375,58 @@ func generateOfficeId(realPath string) string {
return key
}
// Parses raw LRC text into a slice of Lyric.
// Lines with [hh:mm:ss.xx] (timestamps) will be parsed to ms for synced lyrics
// The ones without timestamp are kept as unsynced (timestamp 0).
func parseLRC(raw string) []iteminfo.Lyric {
var lines []iteminfo.Lyric
scanner := bufio.NewScanner(strings.NewReader(raw))
re := regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
matches := re.FindStringSubmatch(line)
if len(matches) == 6 {
// Hours default to 0 (if not present in the timestamps)
h := 0
if matches[1] != "" {
h, _ = strconv.Atoi(matches[1])
}
min, _ := strconv.Atoi(matches[2])
sec, _ := strconv.Atoi(matches[3])
msStr := matches[4]
// Normalise to 3 digits, some songs have more than 3 digits in the milliseconds
switch {
case len(msStr) < 3:
msStr += strings.Repeat("0", 3-len(msStr))
case len(msStr) > 3:
msStr = msStr[:3]
}
ms, _ := strconv.Atoi(msStr)
timestamp := int64((h*3600+min*60+sec)*1000 + ms)
text := strings.TrimSpace(matches[5])
lines = append(lines, iteminfo.Lyric{
Text: text,
Timestamp: timestamp,
})
} else {
// Line without a valid timestamp – treat as unsynced (timestamp 0)
lines = append(lines, iteminfo.Lyric{
Text: line,
Timestamp: 0,
})
}
}
return lines
}
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
// and optionally extracts duration using the ffmpeg service with concurrency control
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
file, err := os.Open(realPath)
if err != nil {
return err
@@ -380,6 +462,30 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
track, _ := m.Track()
item.Metadata.Track = track
// Check if lyrics are available without extract them
if rawLyrics := m.Lyrics(); rawLyrics != "" {
item.Metadata.HasLyrics = true
} else {
// Check for sidecar .lrc file
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
if _, err := os.Stat(lrcPath); err == nil {
item.Metadata.HasLyrics = true
}
}
if getLyrics {
lyrics, err := ExtractLyrics(realPath)
if err != nil {
logger.Debugf("failed to extract lyrics for %s: %v", realPath, err)
} else if len(lyrics) > 0 {
item.Metadata.Lyrics = lyrics
}
}
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
// This respects concurrency limits and gracefully handles missing ffmpeg
if getDuration {
@@ -412,6 +518,37 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
// Returns lyrics from an audio file (from embedded tags or from a .lrc file with the same name).
func ExtractLyrics(realPath string) ([]iteminfo.Lyric, error) {
file, err := os.Open(realPath)
if err != nil {
return nil, err
}
defer file.Close()
m, err := tag.ReadFrom(file)
if err != nil {
return nil, err
}
var lyrics []iteminfo.Lyric
if raw := m.Lyrics(); raw != "" {
lyrics = parseLRC(raw)
}
if len(lyrics) == 0 {
// Check for sidecar .lrc file
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
if data, err := os.ReadFile(lrcPath); err == nil {
lyrics = parseLRC(string(data))
}
}
return lyrics, nil
}
// extractVideoMetadata extracts duration from video files using ffprobe
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
@@ -446,7 +583,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 +620,7 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
if idx == nil {
return fmt.Errorf("could not get index: %v ", source)
}
if idx.Config.DisableIndexing {
if idx.Config.ResolvedRules.IndexingDisabled {
return nil
}
// Always normalize path using MakeIndexPath
@@ -591,7 +728,7 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
// Handle SOURCE cleanup (treat as deletion)
// Run async to avoid blocking the HTTP response
if !srcIdx.Config.DisableIndexing {
if !srcIdx.Config.ResolvedRules.IndexingDisabled {
go func() {
if isSrcDir {
srcIdx.DeleteMetadata(srcIndexPath, true, true)
@@ -606,7 +743,7 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
// Handle DESTINATION indexing
// Run async to avoid blocking the HTTP response
if !dstIdx.Config.DisableIndexing {
if !dstIdx.Config.ResolvedRules.IndexingDisabled {
if isSrcDir {
go func() {
// Recursively index the moved directory tree
@@ -664,7 +801,7 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
// Refresh source (non-recursive, just metadata)
srcIdx := indexing.GetIndex(sourceIndex)
if srcIdx != nil && !srcIdx.Config.DisableIndexing {
if srcIdx != nil && !srcIdx.Config.ResolvedRules.IndexingDisabled {
srcRefreshPath := realsrc
if !isSrcDir {
srcRefreshPath = filepath.Dir(realsrc)
@@ -675,7 +812,7 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
// Refresh destination (RECURSIVE for directories to capture full tree)
// Run async to avoid blocking the HTTP response
dstIdx := indexing.GetIndex(destIndex)
if dstIdx != nil && !dstIdx.Config.DisableIndexing {
if dstIdx != nil && !dstIdx.Config.ResolvedRules.IndexingDisabled {
if isSrcDir {
go func() {
// Recursively index the copied directory tree
@@ -762,22 +899,21 @@ func WriteFile(source, path string, in io.Reader) error {
if err != nil {
return err
}
applyDefaultFilePerm := false
var stat os.FileInfo
// Check if the destination exists and is a directory
if stat, err = os.Stat(realPath); err == nil {
if stat.IsDir() {
// If it's a directory and we're trying to create a file, remove the directory first
err = os.RemoveAll(realPath)
if err != nil {
return fmt.Errorf("could not remove existing directory to create file: %v", err)
}
stat, err = os.Stat(realPath)
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("stat file: %w", err)
}
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
applyDefaultFilePerm = true
} else if stat.IsDir() {
return fmt.Errorf("%w: %q", errors.ErrIsDirectory, path)
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
// For new files: permissions are set to fileutils.PermFile (subject to umask, which is usually acceptable)
// For existing files: permissions are preserved automatically (O_TRUNC doesn't change them)
// For new files: permissions are set to fileutils.PermFile (subject to umask, then Chmod bypasses umask)
// For existing regular files: do not Chmod so saves preserve mode and special bits
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
@@ -790,11 +926,11 @@ func WriteFile(source, path string, in io.Reader) error {
return err
}
// Explicitly set file permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
// Handle chmod error gracefully
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
if applyDefaultFilePerm {
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
}
}
// Refresh the file itself
+1
View File
@@ -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",
+5 -1
View File
@@ -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)
}
+58 -3
View File
@@ -12,9 +12,45 @@ import (
var PermFile os.FileMode
var PermDir os.FileMode
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
PermFile = PermFileOctal
PermDir = PermDirOctal
// SetFsPermissions sets create modes from Unix chmod(2)-style values (for example
// values produced by strconv.ParseUint(s, 8, 32)). setuid, setgid, and sticky
// bits must be converted for Go's os.FileMode; a plain cast from Unix octal is
// incorrect for those bits.
func SetFsPermissions(unixFileMode, unixDirMode uint32) {
PermFile = unixModeToFileMode(unixFileMode)
PermDir = unixModeToFileMode(unixDirMode)
}
// EffectiveDirPerm returns [PermDir] if [SetFsPermissions] was called, otherwise 0o755. On Unix,
// [os.Mkdir] with perm 0 fails with permission denied, so use this (or [PermFile]) for creates when
// the process has not set globals yet.
func EffectiveDirPerm() os.FileMode {
if PermDir == 0 {
return 0o755
}
return PermDir
}
// EffectiveFilePerm returns [PermFile] if set, otherwise 0o644.
func EffectiveFilePerm() os.FileMode {
if PermFile == 0 {
return 0o644
}
return PermFile
}
func unixModeToFileMode(u uint32) os.FileMode {
m := os.FileMode(u & 0777)
if u&0o4000 != 0 {
m |= os.ModeSetuid
}
if u&0o2000 != 0 {
m |= os.ModeSetgid
}
if u&0o1000 != 0 {
m |= os.ModeSticky
}
return m
}
// MoveFile moves a file from src to dst.
@@ -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
}
+17 -1
View File
@@ -1,6 +1,22 @@
package fileutils
import "testing"
import (
"os"
"testing"
)
func TestUnixModeToFileMode_setgid(t *testing.T) {
m := unixModeToFileMode(0o2770)
if got := m.Perm(); got != 0o770 {
t.Fatalf("Perm() = %#o, want 0770", got)
}
if m&os.ModeSetgid == 0 {
t.Fatal("expected ModeSetgid")
}
if m&os.ModeSetuid != 0 || m&os.ModeSticky != 0 {
t.Fatalf("unexpected special bits: %#o", m)
}
}
func TestCommonPrefix(t *testing.T) {
testCases := map[string]struct {
+4 -4
View File
@@ -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
View File
@@ -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
}
+142
View File
@@ -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"},
+3 -4
View File
@@ -146,14 +146,14 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
}
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
// get data from cache
cachedSecret, found := TotpCache.Get(user.Username)
if !found && user.TOTPSecret == "" {
return fmt.Errorf("OTP token not found in cache, please generate a new one")
}
useCache := found && user.TOTPSecret == ""
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
if found {
if useCache {
splitSecret := strings.Split(cachedSecret, "||")
if len(splitSecret) < 2 {
return fmt.Errorf("invalid cached OTP token format")
@@ -173,9 +173,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
}
secretToValidate = decryptedSecret
}
// --- END: ADD THIS DECRYPTION LOGIC ---
// Validate the token using the (now plaintext) secret.
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
Period: TOTPPeriod,
Skew: TOTPSkew,
@@ -198,6 +196,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
logger.Error("error updating user with OTP token:", err)
return fmt.Errorf("error updating user with OTP token: %w", err)
}
TotpCache.Delete(user.Username)
} else {
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
}
+296
View File
@@ -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
View File
@@ -23,7 +23,7 @@ var (
)
// return bool to indicate if the program should continue running
func runCLI() bool {
func runCLI() (bool, bool) {
generateYaml()
@@ -53,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
View File
@@ -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
View File
@@ -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
}
+1
View File
@@ -25,6 +25,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
"sourceA": &sourceA,
"sourceB": &sourceB,
}
settings.InitializeUserResolvers()
// ---------------------
// Test Cases Definition
+7 -1
View File
@@ -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)
}
}
+12 -2
View File
@@ -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
+406 -36
View File
@@ -39,6 +39,8 @@ func Initialize(configFile string) {
setupLogging()
// setup logging first to ensure we log any errors
setupEnv()
// Migrate deprecated UserDefaults fields to new organized structure
migrateUserDefaults()
err = ValidateConfig(Config)
if err != nil {
errmsg := "The provided config file failed validation. "
@@ -51,6 +53,7 @@ func Initialize(configFile string) {
}
setupFs()
setupServer()
setupHttp()
setupAuth(false)
setupSources(false)
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
@@ -81,6 +84,15 @@ func setupEnv() {
}
}
func setupHttp() {
if len(Config.Http.TrustedHeadersArray) > 0 {
Config.Http.TrustedHeaders = make(map[string]bool)
for _, header := range Config.Http.TrustedHeadersArray {
Config.Http.TrustedHeaders[strings.ToLower(header)] = true
}
}
}
func setupFs() {
// Convert permission values (like 644, 755) to octal interpretation
filePermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateFilePermission, 8, 32)
@@ -93,11 +105,15 @@ func setupFs() {
Config.Server.Filesystem.CreateDirectoryPermission = "755"
dirPermOctal, _ = strconv.ParseUint("755", 8, 32)
}
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
fileutils.SetFsPermissions(uint32(filePermOctal), uint32(dirPermOctal))
// Perform mandatory cache directory speed test
testCacheDirSpeed()
if err := PrepareDownloadSpoolDir(); err != nil {
logger.Fatalf("cacheDir failed to prepare download spool: %v", err)
}
logger.Infof("cache directory setup successfully: %v", Config.Server.CacheDir)
}
@@ -462,13 +478,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 +500,324 @@ func setupLogging() {
},
}
}
for _, logConfig := range Config.Server.Logging {
for i := range Config.Server.Logging {
cfg := &Config.Server.Logging[i]
// Enable debug logging automatically in dev mode
levels := logConfig.Levels
levels := cfg.Levels
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
levels = "info|warning|error|debug"
}
logConfig := logger.JsonConfig{
Levels: levels,
ApiLevels: logConfig.ApiLevels,
Output: logConfig.Output,
Utc: logConfig.Utc,
NoColors: logConfig.NoColors,
Json: logConfig.Json,
Structured: false,
pattern := cfg.ApiFilter
if pattern == "" {
pattern = "^/health|^/favicon.ico|^/static|^/public/static"
}
err := logger.EnableCompatibilityMode(logConfig)
jsonCfg := logger.JsonConfig{
Levels: levels,
ApiLevels: cfg.ApiLevels,
Output: cfg.Output,
Utc: cfg.Utc,
NoColors: cfg.NoColors,
Json: cfg.Json,
Structured: false,
ApiPathExclude: pattern,
}
err := logger.EnableCompatibilityMode(jsonCfg)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
}
}
// migrateUserDefaults migrates deprecated UserDefaults fields to the new organized structure.
// This function maintains backwards compatibility by checking if new fields are unset and copying
// values from old deprecated fields if found. Logs warnings when old fields are migrated.
func migrateUserDefaults() {
ud := &Config.UserDefaults
hasOldFields := false
// Helper function to check if a bool pointer is unset (nil)
isUnsetBoolPtr := func(v *bool) bool {
return v == nil
}
// Helper function to check if a string is unset (empty)
isUnsetString := func(v string) bool {
return v == ""
}
// Helper function to check if an int is unset (zero)
isUnsetInt := func(v int) bool {
return v == 0
}
// Migrate Preview-related deprecated fields
if !ud.Sidebar.DisableHideOnPreview && ud.Preview.DisableHideSidebar {
ud.Sidebar.DisableHideOnPreview = ud.Preview.DisableHideSidebar
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'preview.disableHideSidebar' to 'sidebar.disableHideOnPreview'")
}
if !ud.FileViewer.DefaultMediaPlayer && ud.Preview.DefaultMediaPlayer {
ud.FileViewer.DefaultMediaPlayer = ud.Preview.DefaultMediaPlayer
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'preview.defaultMediaPlayer' to 'fileViewer.defaultMediaPlayer'")
}
if isUnsetBoolPtr(ud.FileViewer.AutoplayMedia) && ud.Preview.AutoplayMedia {
autoplay := ud.Preview.AutoplayMedia
ud.FileViewer.AutoplayMedia = &autoplay
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'preview.autoplayMedia' to 'fileViewer.autoplayMedia'")
}
if isUnsetString(ud.Preview.DisablePreviewExt) && !isUnsetString(ud.DisablePreviewExt) {
ud.Preview.DisablePreviewExt = ud.DisablePreviewExt
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disablePreviewExt' to 'preview.disablePreviewExt'")
}
// Migrate Sidebar fields
if !ud.Sidebar.DisableQuickToggles && ud.DisableQuickToggles {
ud.Sidebar.DisableQuickToggles = ud.DisableQuickToggles
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableQuickToggles' to 'sidebar.disableQuickToggles'")
}
if !ud.Sidebar.HideFileActions && ud.HideSidebarFileActions {
ud.Sidebar.HideFileActions = ud.HideSidebarFileActions
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'hideSidebarFileActions' to 'sidebar.hideFileActions'")
}
if !ud.Sidebar.Sticky && ud.StickySidebar {
ud.Sidebar.Sticky = ud.StickySidebar
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'stickySidebar' to 'sidebar.sticky'")
}
if isUnsetString(ud.Listing.ViewMode) && !isUnsetString(ud.ViewMode) {
ud.Listing.ViewMode = ud.ViewMode
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'viewMode' to 'sidebar.viewMode'")
}
if isUnsetInt(ud.Listing.GallerySize) && !isUnsetInt(ud.GallerySize) {
ud.Listing.GallerySize = ud.GallerySize
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'gallerySize' to 'sidebar.gallerySize'")
}
if !ud.Sidebar.HideFiles && ud.HideFilesInTree {
ud.Sidebar.HideFiles = ud.HideFilesInTree
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'hideFilesInTree' to 'sidebar.hideFiles'")
}
if isUnsetBoolPtr(ud.Sidebar.ShowTools) && ud.ShowToolsInSidebar != nil {
ud.Sidebar.ShowTools = ud.ShowToolsInSidebar
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'showToolsInSidebar' to 'sidebar.showTools'")
}
// Migrate Listing fields
if !ud.Listing.DeleteWithoutConfirming && ud.DeleteWithoutConfirming {
ud.Listing.DeleteWithoutConfirming = ud.DeleteWithoutConfirming
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'deleteWithoutConfirming' to 'listing.deleteWithoutConfirming'")
}
if !ud.Listing.DateFormat && ud.DateFormat {
ud.Listing.DateFormat = ud.DateFormat
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'dateFormat' to 'listing.dateFormat'")
}
if !ud.Listing.ShowHidden && ud.ShowHidden {
ud.Listing.ShowHidden = ud.ShowHidden
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'showHidden' to 'listing.showHidden'")
}
if !ud.Listing.QuickDownload && ud.QuickDownload {
ud.Listing.QuickDownload = ud.QuickDownload
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'quickDownload' to 'listing.quickDownload'")
}
if !ud.Listing.ShowSelectMultiple && ud.ShowSelectMultiple {
ud.Listing.ShowSelectMultiple = ud.ShowSelectMultiple
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'showSelectMultiple' to 'listing.showSelectMultiple'")
}
if !ud.Listing.SingleClick && ud.SingleClick {
ud.Listing.SingleClick = ud.SingleClick
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'singleClick' to 'listing.singleClick'")
}
if isUnsetString(ud.Listing.HideFileExt) && !isUnsetString(ud.HideFileExt) {
ud.Listing.HideFileExt = ud.HideFileExt
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'hideFileExt' to 'listing.hideFileExt'")
}
if !ud.Listing.ShowCopyPath && ud.ShowCopyPath {
ud.Listing.ShowCopyPath = ud.ShowCopyPath
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'showCopyPath' to 'listing.showCopyPath'")
}
if !ud.Listing.DeleteAfterArchive && ud.DeleteAfterArchive {
ud.Listing.DeleteAfterArchive = ud.DeleteAfterArchive
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'deleteAfterArchive' to 'listing.deleteAfterArchive'")
}
// Migrate FileViewer fields
if !ud.FileViewer.EditorQuickSave && ud.EditorQuickSave {
ud.FileViewer.EditorQuickSave = ud.EditorQuickSave
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'editorQuickSave' to 'fileViewer.editorQuickSave'")
}
if isUnsetString(ud.FileViewer.DisableViewingExt) && !isUnsetString(ud.DisableViewingExt) {
ud.FileViewer.DisableViewingExt = ud.DisableViewingExt
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableViewingExt' to 'fileViewer.disableViewingExt'")
}
if isUnsetString(ud.FileViewer.DisableOnlyOfficeExt) && !isUnsetString(ud.DisableOnlyOfficeExt) {
ud.FileViewer.DisableOnlyOfficeExt = ud.DisableOnlyOfficeExt
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableOnlyOfficeExt' to 'fileViewer.disableOnlyOfficeExt'")
}
if !ud.FileViewer.PreferEditorForMarkdown && ud.PreferEditorForMarkdown {
ud.FileViewer.PreferEditorForMarkdown = ud.PreferEditorForMarkdown
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'preferEditorForMarkdown' to 'fileViewer.preferEditorForMarkdown'")
}
if !ud.FileViewer.DebugOffice && ud.DebugOffice {
ud.FileViewer.DebugOffice = ud.DebugOffice
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'debugOffice' to 'fileViewer.debugOffice'")
}
// Migrate Search fields
if !ud.Search.DisableOptions && ud.DisableSearchOptions {
ud.Search.DisableOptions = ud.DisableSearchOptions
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableSearchOptions' to 'search.disableOptions'")
}
// Migrate UI fields
if isUnsetBoolPtr(ud.UI.DarkMode) && ud.DarkMode != nil {
ud.UI.DarkMode = ud.DarkMode
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'darkMode' to 'ui.darkMode'")
}
if isUnsetString(ud.UI.ThemeColor) && !isUnsetString(ud.ThemeColor) {
ud.UI.ThemeColor = ud.ThemeColor
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'themeColor' to 'ui.themeColor'")
}
if isUnsetString(ud.UI.CustomTheme) && !isUnsetString(ud.CustomTheme) {
ud.UI.CustomTheme = ud.CustomTheme
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'customTheme' to 'ui.customTheme'")
}
if isUnsetString(ud.UI.Locale) && !isUnsetString(ud.Locale) {
ud.UI.Locale = ud.Locale
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'locale' to 'ui.locale'")
}
// Migrate Account fields
if !ud.Account.LockPassword && ud.LockPassword {
ud.Account.LockPassword = ud.LockPassword
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'lockPassword' to 'account.lockPassword'")
}
if !ud.Account.DisableSettings && ud.DisableSettings {
ud.Account.DisableSettings = ud.DisableSettings
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableSettings' to 'account.disableSettings'")
}
if isUnsetString(ud.Account.LoginMethod) && !isUnsetString(ud.LoginMethod) {
ud.Account.LoginMethod = ud.LoginMethod
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'loginMethod' to 'account.loginMethod'")
}
if !ud.Account.DisableUpdateNotifications && ud.DisableUpdateNotifications {
ud.Account.DisableUpdateNotifications = ud.DisableUpdateNotifications
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableUpdateNotifications' to 'account.disableUpdateNotifications'")
}
// Migrate Account Permissions fields
if !ud.Account.Permissions.Api && ud.Permissions.Api {
ud.Account.Permissions.Api = ud.Permissions.Api
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.api' to 'account.permissions.api'")
}
if !ud.Account.Permissions.Admin && ud.Permissions.Admin {
ud.Account.Permissions.Admin = ud.Permissions.Admin
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.admin' to 'account.permissions.admin'")
}
if !ud.Account.Permissions.Modify && ud.Permissions.Modify {
ud.Account.Permissions.Modify = ud.Permissions.Modify
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.modify' to 'account.permissions.modify'")
}
if !ud.Account.Permissions.Share && ud.Permissions.Share {
ud.Account.Permissions.Share = ud.Permissions.Share
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.share' to 'account.permissions.share'")
}
if !ud.Account.Permissions.Realtime && ud.Permissions.Realtime {
ud.Account.Permissions.Realtime = ud.Permissions.Realtime
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.realtime' to 'account.permissions.realtime'")
}
if !ud.Account.Permissions.Delete && ud.Permissions.Delete {
ud.Account.Permissions.Delete = ud.Permissions.Delete
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.delete' to 'account.permissions.delete'")
}
if !ud.Account.Permissions.Create && ud.Permissions.Create {
ud.Account.Permissions.Create = ud.Permissions.Create
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.create' to 'account.permissions.create'")
}
if isUnsetBoolPtr(ud.Account.Permissions.Download) && ud.Permissions.Download != nil {
ud.Account.Permissions.Download = ud.Permissions.Download
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.download' to 'account.permissions.download'")
}
if hasOldFields {
logger.Warning("userDefaults: Please update your configuration to use the new organized structure. See documentation for details.")
}
}
func loadConfigWithDefaults(configFile string, generate bool) error {
Config = setDefaults(generate)
Config = SetDefaults(generate)
// Check if config file exists
if _, err := os.Stat(configFile); err != nil {
@@ -657,7 +970,7 @@ func loadEnvConfig() {
}
}
func setDefaults(generate bool) Settings {
func SetDefaults(generate bool) Settings {
// get number of CPUs available
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
cpus := runtime.NumCPU()
@@ -708,25 +1021,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 +1050,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 +1167,26 @@ func loadCustomFavicon() {
Env.FaviconPath = validatedPath
Env.FaviconIsCustom = true
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
// PWA and platform PNGs are generated at server startup from this path (SVG uses a raster
// sidecar next to the .svg when present; see icons.GeneratePWAIcons).
}
// 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() {
+4 -4
View File
@@ -35,7 +35,7 @@ func Test_setDefaults(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setDefaults(true); !reflect.DeepEqual(got, tt.want) {
if got := SetDefaults(true); !reflect.DeepEqual(got, tt.want) {
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
}
})
@@ -54,7 +54,7 @@ func TestConfigLoadChanged(t *testing.T) {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := setDefaults(true)
defaultConfig := SetDefaults(true)
err = loadConfigWithDefaults(configFile, true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
@@ -77,7 +77,7 @@ func TestConfigLoadEnvVars(t *testing.T) {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := setDefaults(true)
defaultConfig := SetDefaults(true)
expectedKey := "MYKEY"
// mock environment variables
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
@@ -106,7 +106,7 @@ func TestConfigLoadSpecificValues(t *testing.T) {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := setDefaults(true)
defaultConfig := SetDefaults(true)
err = loadConfigWithDefaults(configFile, true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
+12 -12
View File
@@ -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
}
+221
View File
@@ -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")
}
}
+95 -47
View File
@@ -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
}
+4 -4
View File
@@ -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)
+148 -58
View File
@@ -20,6 +20,14 @@ type Settings struct {
Frontend Frontend `json:"frontend"`
UserDefaults UserDefaults `json:"userDefaults"`
Integrations Integrations `json:"integrations"`
Http Http `json:"http"`
}
type Http struct {
TrustedHeadersArray []string `json:"trustedHeaders"` // list of headers to trust, useful when behind a reverse proxy.
DisableRateLimit bool `json:"disableRateLimit"` // turns off built-in auth route rate limiting and failed-login lockout (default false).
// internal map of trusted headers
TrustedHeaders map[string]bool `json:"-"`
}
type Environment struct {
@@ -36,10 +44,6 @@ type Environment struct {
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
PWAIconsDir string `json:"-"` // directory where generated PWA icons are stored
PWAIcon192 string `json:"-"` // path to 192x192 PWA icon
PWAIcon256 string `json:"-"` // path to 256x256 PWA icon
PWAIcon512 string `json:"-"` // path to 512x512 PWA icon
}
type Server struct {
@@ -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
}
+28
View File
@@ -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
}
+23
View File
@@ -57,6 +57,7 @@ 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)
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 +87,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
}
+5 -6
View File
@@ -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.
+71 -38
View File
@@ -5,51 +5,84 @@ server:
- levels: "info|warning|error"
sources:
- path: "/srv"
auth:
key: "change-me-to-a-random-string"
totpSecret: "change-me-to-another-random-string"
methods:
password:
enabled: true
minLength: 5
signup: false
passkey:
enabled: false
userDefaults:
editorQuickSave: false # show quick save button in editor
hideSidebarFileActions: false # hide the file actions in the sidebar
disableQuickToggles: false # disable the quick toggles in the sidebar
disableSearchOptions: false # disable the search options in the search bar
stickySidebar: true # keep sidebar open when navigating
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
+6
View File
@@ -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
}
+3 -2
View File
@@ -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"`
}
+87 -82
View File
@@ -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
}
+29 -2
View File
@@ -18,9 +18,27 @@ type SearchItem struct {
HasPreview bool
}
// appendNameGlobSQL adds AND (name GLOB ? OR ...) or AND (name GLOB ? AND ...) when joinAnd is true.
func appendNameGlobSQL(query string, args []interface{}, patterns []string, joinAnd bool) (string, []interface{}) {
if len(patterns) == 0 {
return query, args
}
parts := make([]string, len(patterns))
for i := range patterns {
parts[i] = "name GLOB ?"
args = append(args, patterns[i])
}
join := " OR "
if joinAnd {
join = " AND "
}
return query + " AND (" + strings.Join(parts, join) + ")", args
}
// SearchItems queries the database for items matching the search criteria for a single source.
// When nameGlobPatterns is non-empty (and largest is false), rows are restricted with SQLite name GLOB ... OR ....
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.Rows, error) {
func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd bool) (*sql.Rows, error) {
query := `
SELECT path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
@@ -35,6 +53,10 @@ func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.
args = append(args, scope+"*")
}
if !largest && len(nameGlobPatterns) > 0 {
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd)
}
if largest {
query += " ORDER BY size DESC"
}
@@ -43,8 +65,9 @@ func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.
}
// SearchItemsMultiSource queries the database for items matching the search criteria across multiple sources.
// When nameGlobPatterns is non-empty (and largest is false), restricts rows with SQLite name GLOB ... OR ....
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool) (*sql.Rows, error) {
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd bool) (*sql.Rows, error) {
if len(sources) == 0 {
return nil, fmt.Errorf("at least one source is required")
}
@@ -95,6 +118,10 @@ func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[str
query += " WHERE " + strings.Join(whereClauses, " AND ")
}
if !largest && len(nameGlobPatterns) > 0 {
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd)
}
if largest {
query += " ORDER BY size DESC"
}
+14 -9
View File
@@ -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()
}
+3 -8
View File
@@ -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)
+38 -63
View File
@@ -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, "/") {
+59 -20
View File
@@ -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,31 +96,31 @@ type Preview struct {
Models bool `json:"models"` // show live thumbnails for 3D models files
}
// User describes a user.
// User describes a persisted account. Scopes and source sidebar links store filesystem paths in Name/SourceName;
// JSON responses use display names only (see http prepForFrontend).
type User struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Scopes []SourceScope `json:"scopes"`
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
Version int `json:"version"`
ShowFirstLogin bool `json:"showFirstLogin"`
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Scopes []SourceScope `json:"scopes"`
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
Version int `json:"version"`
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"` // 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
@@ -109,6 +139,7 @@ type NonAdminEditable struct {
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
@@ -122,12 +153,15 @@ type NonAdminEditable struct {
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
ShowFirstLogin bool `json:"showFirstLogin"`
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
}
type FileLoading struct {
@@ -143,7 +177,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 +224,11 @@ func SetSourceConfig(config *SourceConfigProvider) {
sourceConfig = config
}
// SourceConfigLoaded reports whether SetSourceConfig has been called (needed for ResolveSourceKey).
func SourceConfigLoaded() bool {
return sourceConfig != nil
}
// GetScopeForSourcePath returns the scope for a given source path (backend-style)
// This method works with backend-style scopes where Name is the source path
func (u *User) GetScopeForSourcePath(sourcePath string) (string, error) {
+127
View File
@@ -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()
}
+67 -54
View File
@@ -11,33 +11,35 @@ require (
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/go-webauthn/webauthn v0.17.3
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.0.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.21
github.com/mattn/go-sqlite3 v1.14.42
github.com/mattn/go-sqlite3 v1.14.44
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.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.51.0
golang.org/x/image v0.40.0
golang.org/x/mod v0.36.0
golang.org/x/net v0.54.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.43.0
golang.org/x/sys v0.44.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.43.0
golang.org/x/tools v0.45.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.48.2
modernc.org/sqlite v1.50.1
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
charm.land/lipgloss/v2 v2.0.3 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
@@ -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.1 // indirect
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
@@ -66,38 +69,40 @@ 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
@@ -107,6 +112,7 @@ require (
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
@@ -114,16 +120,16 @@ require (
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/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.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/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -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.5 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.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-20260512202753-e3c51de6d1b6 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.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,16 @@ 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/jupiterrider/ffi v0.7.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 +195,32 @@ require (
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.5.0 // indirect
github.com/manuelarte/funcorder v0.6.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.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 +238,17 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/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 +260,18 @@ require (
github.com/subosito/gotenv v1.4.1 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tetafro/godot v1.5.4 // indirect
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/uudashr/gocognit v1.2.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,7 +279,7 @@ 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
@@ -276,12 +289,12 @@ require (
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/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+152 -172
View File
@@ -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.1 h1:G4hy4YemKrIcGAvYMor+86PhxdtBEq0n9ebJ4RoYy8w=
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.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,6 +206,10 @@ 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=
@@ -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=
@@ -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,6 +277,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
@@ -314,33 +316,33 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
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/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.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.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -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.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@@ -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-20260512202753-e3c51de6d1b6 h1:maAhA7cpighmEi96eTgDdmlhwnHYEgwhR6t+T7k3Png=
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -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,10 @@ 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/jupiterrider/ffi v0.7.0 h1:RKsl6Ascal+3kyAqR5Qcbp83LceQMLc1VZbPfHWoNzs=
github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@@ -626,8 +634,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE=
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
@@ -638,8 +646,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww=
github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM=
github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8=
github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA=
github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0=
github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g=
github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8=
github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ=
github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs=
@@ -652,16 +660,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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.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 +685,10 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
@@ -707,8 +713,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 +728,12 @@ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2D
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -770,7 +778,6 @@ github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74
github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -783,8 +790,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 +802,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 +814,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 +863,16 @@ github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
github.com/tetafro/godot v1.5.4 h1:u1ww+gqpRLiIA16yF2PV1CV1n/X3zhyezbNXC3E14Sg=
github.com/tetafro/godot v1.5.4/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0=
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
@@ -880,16 +883,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 +909,6 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
@@ -919,8 +921,8 @@ 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=
@@ -935,6 +937,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 +952,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -970,8 +972,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.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -991,14 +993,11 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1031,18 +1030,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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1064,8 +1059,6 @@ 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/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1103,9 +1096,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 +1107,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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1135,10 +1120,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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1188,14 +1171,11 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1281,8 +1261,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -1312,10 +1292,10 @@ 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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -1324,18 +1304,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+20 -4
View File
@@ -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
View File
@@ -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
}
+289
View File
@@ -0,0 +1,289 @@
package http
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeArchiveEntryName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErr string
}{
{
name: "simple forward slashes",
input: "DOCS/guide.pdf",
want: "DOCS/guide.pdf",
},
{
name: "Windows backslashes become logical path",
input: `DOCS\QUICK\chs.gif`,
want: "DOCS/QUICK/chs.gif",
},
{
name: "Intel-style leading backslash stripped so path stays relative",
input: `\PRO1000\Winx64\NDIS65\e1c65x64.cat`,
want: "PRO1000/Winx64/NDIS65/e1c65x64.cat",
},
{
name: "leading forward slashes stripped",
input: "/PRO1000/Winx64/readme.txt",
want: "PRO1000/Winx64/readme.txt",
},
{
name: "directory entry with trailing slash",
input: `PRO1000\Winx64\`,
want: "PRO1000/Winx64",
},
{
name: "mixed redundant segments cleaned",
input: "a/./b/../c/file",
want: "a/c/file",
},
{
name: "empty",
input: "",
wantErr: "empty path",
},
{
name: "whitespace only",
input: " ",
wantErr: "empty path",
},
{
name: "UNC rejected",
input: `//server/share/file`,
wantErr: "UNC or invalid path",
},
{
name: "double slash prefix after backslash replace (UNC-style)",
input: `\\?\C:\temp\file`,
wantErr: "UNC or invalid path",
},
{
name: "Windows drive absolute rejected",
input: `C:/Windows/system32`,
wantErr: "absolute path in archive",
},
{
name: "lowercase drive rejected",
input: `c:/data/file.txt`,
wantErr: "absolute path in archive",
},
{
name: "bare dot-dot",
input: "..",
wantErr: "path traversal",
},
{
name: "prefixed dot-dot",
input: "../etc/passwd",
wantErr: "path traversal",
},
{
name: "traversal after path clean",
input: "safe/../../outside",
wantErr: "path traversal after clean",
},
{
name: "only dots and slashes",
input: "./",
wantErr: "empty path after clean",
},
{
name: "path cleans to nothing",
input: "x/../.",
wantErr: "empty path after clean",
},
{
name: "spaces preserved in file name",
input: "My Folder/read me.txt",
want: "My Folder/read me.txt",
},
{
name: "deep relative tree",
input: "a/b/c/d/e/f/g.bin",
want: "a/b/c/d/e/f/g.bin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeArchiveEntryName(tt.input)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil (result %q)", tt.wantErr, got)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("normalizeArchiveEntryName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
// TestNormalizeArchiveEntryName_backslashInFileNameOnUnix documents that after normalization
// a single backslash in the raw name is never a path separator: it becomes a slash in the
// logical path (portable), so segments split correctly on every GOOS in safeExtractPath.
func TestNormalizeArchiveEntryName_unifiesBackslashes(t *testing.T) {
t.Parallel()
got, err := normalizeArchiveEntryName(`folder\file.txt`)
if err != nil {
t.Fatal(err)
}
if want := "folder/file.txt"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestSafeExtractPath(t *testing.T) {
t.Parallel()
dest := t.TempDir()
dest, err := filepath.Abs(dest)
if err != nil {
t.Fatal(err)
}
t.Run("file under dest", func(t *testing.T) {
t.Parallel()
got, err := safeExtractPath(dest, `DOCS\Adapter_User_Guide.pdf`)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(dest, "DOCS", "Adapter_User_Guide.pdf")
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("leading backslash in zip name stays under dest", func(t *testing.T) {
t.Parallel()
got, err := safeExtractPath(dest, `\sub\file.txt`)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got, dest+string(filepath.Separator)) {
t.Fatalf("result %q should be under dest %q", got, dest)
}
rel, err := filepath.Rel(dest, got)
if err != nil {
t.Fatal(err)
}
if rel != filepath.Join("sub", "file.txt") {
t.Fatalf("Rel = %q, want sub%cfile.txt", rel, filepath.Separator)
}
})
t.Run("rejects path that escapes dest", func(t *testing.T) {
t.Parallel()
_, err := safeExtractPath(dest, "../../../x")
if err == nil {
t.Fatal("expected error for parent segments in archive name")
}
if !strings.Contains(err.Error(), "invalid entry path") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("rejects when normalize fails", func(t *testing.T) {
t.Parallel()
_, err := safeExtractPath(dest, "..")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "invalid entry path") {
t.Fatalf("got %v", err)
}
})
}
// TestSafeExtractPath_rejectsParentTraversal ensures ".." in the archive name is rejected
// by normalizeArchiveEntryName, not written under dest.
func TestSafeExtractPath_rejectsParentTraversal(t *testing.T) {
t.Parallel()
dest := t.TempDir()
_, err := safeExtractPath(dest, `pkg/../../../secret`)
if err == nil {
t.Fatal("expected error for path traversal in archive name")
}
}
// TestSafeExtractPath_destJoinDoesNotDropBase verifies that a previously problematic pattern
// (root-relative second segment) does not throw away dest after normalization.
func TestSafeExtractPath_destJoinDoesNotDropBase(t *testing.T) {
t.Parallel()
dest := t.TempDir()
out, err := safeExtractPath(dest, `a\b\c.txt`)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(out, dest+string(filepath.Separator)) {
t.Fatalf("expected path under %q, got %q", dest, out)
}
}
// TestExtractZip_nestedBackslashes extracts a Windows-style path in a zip (backslashes) into
// nested directories under dest. This matches many vendor driver zips.
func TestExtractZip_nestedBackslashes(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "test.zip")
out := filepath.Join(tmp, "out")
if err := os.MkdirAll(out, 0o755); err != nil {
t.Fatal(err)
}
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
w, err := zw.Create(`DOCS\QUICK\readme.txt`)
if err != nil {
t.Fatal(err)
}
if _, err = w.Write([]byte("hello")); err != nil {
t.Fatal(err)
}
if err = zw.Close(); err != nil {
t.Fatal(err)
}
if err = f.Close(); err != nil {
t.Fatal(err)
}
if err = extractZip(zipPath, out); err != nil {
t.Fatalf("extractZip: %v", err)
}
b, err := os.ReadFile(filepath.Join(out, "DOCS", "QUICK", "readme.txt"))
if err != nil {
t.Fatal(err)
}
if string(b) != "hello" {
t.Fatalf("content = %q", b)
}
}
// Benchmarks (optional, keep small)
func BenchmarkNormalizeArchiveEntryName(b *testing.B) {
const name = `PRO1000\Winx64\NDIS65\e1c65x64.cat`
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = normalizeArchiveEntryName(name)
}
}
+15 -10
View File
@@ -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
+269
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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 {
+24 -14
View File
@@ -80,6 +80,10 @@ 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)
// ========================================
@@ -92,18 +96,23 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
// ========================================
// 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)
@@ -150,7 +159,7 @@ 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))
@@ -177,7 +186,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 +203,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)
+3 -3
View File
@@ -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,
+72 -3
View File
@@ -2,6 +2,7 @@ package http
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
@@ -9,7 +10,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/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
@@ -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,70 @@ 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")
}
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, err := idx.GetRealPath(userscope, path)
if err != nil {
return http.StatusNotFound, err
}
lyrics, err := files.ExtractLyrics(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")
}
idx := indexing.GetIndex(sourceCfg.Name)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found")
}
realPath, _, err := idx.GetRealPath(d.IndexPath)
if err != nil {
return http.StatusNotFound, errors.New("could not find real path for file")
}
lyrics, err := files.ExtractLyrics(realPath)
if err != nil {
return http.StatusInternalServerError, errors.New("failed to extract lyrics")
}
return renderJSON(w, r, map[string]any{"lyrics": lyrics})
}
+30 -58
View File
@@ -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,8 +133,9 @@ 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,
}, store.Access, data.shareUser, store.Share)
if err != nil {
@@ -222,57 +228,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 +251,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 +263,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 +282,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 +312,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 +627,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 +723,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 +731,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 +798,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,
+2 -4
View File
@@ -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)
+121 -16
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
@@ -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
@@ -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")
+102
View File
@@ -0,0 +1,102 @@
package http
import (
"net/url"
"testing"
)
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 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")
}
}
+3 -4
View File
@@ -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"
@@ -105,7 +104,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 +120,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")
}
@@ -214,7 +213,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"
+12 -4
View File
@@ -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
+116 -26
View File
@@ -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,8 +98,9 @@ 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,
}, store.Access, d.user, store.Share)
@@ -145,18 +145,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 +261,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 +350,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 +362,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 +373,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 +383,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 +417,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 +535,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 +557,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 +581,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 +770,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 +906,28 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
continue
}
if srcIdx.Config.ReadOnly && req.Action == "move" {
item.Message = "source source is read-only"
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 +1124,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
View File
@@ -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
+7 -1
View File
@@ -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
}
+49 -68
View File
@@ -83,7 +83,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 +95,8 @@ 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 {
if d.share.Favicon != "" {
if strings.HasPrefix(d.share.Favicon, "http") {
favicon = d.share.Favicon
@@ -139,9 +139,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,22 +158,9 @@ 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
// 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"
}
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
data["htmlVars"] = map[string]interface{}{
"title": title,
@@ -186,8 +173,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,
@@ -198,39 +185,41 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"pwaIcon512": pwaIcon512,
"manifestURL": staticURL + "/site.webmanifest",
}
// 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
@@ -313,7 +302,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 +312,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
View File
@@ -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
}
+109 -25
View File
@@ -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,58 @@ 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.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 +231,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 +255,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 +270,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 +309,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 +323,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 +384,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 +445,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
}
+2
View File
@@ -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
}
+240
View File
@@ -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
}
+2
View File
@@ -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
View File
@@ -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)
+9 -21
View File
@@ -29,7 +29,6 @@ const (
type iconSize struct {
size int
name string
env *string
}
// determineSourceType determines which type of favicon source we have
@@ -153,13 +152,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 +180,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 +194,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)
@@ -233,18 +228,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()
+9 -19
View File
@@ -1,9 +1,6 @@
package icons
import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
@@ -36,6 +33,9 @@ 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,
@@ -68,7 +68,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 +78,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)
}
+1 -2
View File
@@ -14,8 +14,7 @@ func setupConditionalTestIndex(conditionals settings.ConditionalFilter) *Index {
Name: "test",
Path: "/test/path",
Config: settings.SourceConfig{
DisableIndexing: false,
Conditionals: conditionals,
Conditionals: conditionals,
},
}
+3 -5
View File
@@ -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
+29 -25
View File
@@ -16,44 +16,26 @@ func TestFetchExtendedAttributes(t *testing.T) {
tests := []struct {
name string
adjustedPath string
combinedPath string
files []os.FileInfo
opts Options
expectHasPreview bool
expectMapNotEmpty bool
}{
{
name: "SkipExtendedAttrs returns empty",
adjustedPath: "/test/",
combinedPath: "/test/",
files: []os.FileInfo{},
opts: Options{
SkipExtendedAttrs: true,
Recursive: false,
},
expectHasPreview: false,
name: "SkipExtendedAttrs returns empty",
adjustedPath: "/test/",
opts: Options{SkipExtendedAttrs: true, Recursive: false},
expectMapNotEmpty: false,
},
{
name: "Recursive returns empty",
adjustedPath: "/test/",
combinedPath: "/test/",
files: []os.FileInfo{},
opts: Options{
SkipExtendedAttrs: false,
Recursive: true,
},
expectHasPreview: false,
name: "Recursive returns empty",
adjustedPath: "/test/",
opts: Options{SkipExtendedAttrs: false, Recursive: true},
expectMapNotEmpty: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasPreview, subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.files, tt.opts)
if hasPreview != tt.expectHasPreview {
t.Errorf("expected hasPreview=%v, got %v", tt.expectHasPreview, hasPreview)
}
subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.opts)
if (len(subdirMap) > 0) != tt.expectMapNotEmpty {
t.Errorf("expected mapNotEmpty=%v, got %v", tt.expectMapNotEmpty, len(subdirMap) > 0)
}
@@ -61,6 +43,28 @@ func TestFetchExtendedAttributes(t *testing.T) {
}
}
// TestFetchExtendedAttributes_subdirHasPreviewFromMetadata checks folder hasPreview comes from one GetMetadataInfo (non-shallow).
func TestFetchExtendedAttributes_subdirHasPreviewFromMetadata(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
deepdir, err := idx.db.GetItem("test", "/subdir/deepdir/")
if err != nil || deepdir == nil {
t.Fatalf("deepdir row: err=%v", err)
}
deepdir.HasPreview = true
_ = idx.db.InsertItem("test", "/subdir/deepdir/", deepdir)
subdirMap := idx.fetchExtendedAttributes("/subdir/", Options{
SkipExtendedAttrs: false,
Recursive: false,
})
if !subdirMap["/subdir/deepdir/"] {
t.Errorf("expected hasPreview map for /subdir/deepdir/, got %v", subdirMap)
}
}
// TestGetDirectoryName tests the directory name extraction logic
func TestGetDirectoryName(t *testing.T) {
tests := []struct {
+106 -171
View File
@@ -29,7 +29,7 @@ var (
// This is a simplified helper for shallow calculations and API calls
// For scanning with hardlink detection, use handleFile instead
// If useLogicalSize is true, returns the logical file size instead
func getDiskUsage(fileInfo os.FileInfo, realPath string, useLogicalSize bool) int64 {
func getDiskUsage(fileInfo os.FileInfo, useLogicalSize bool) int64 {
// If useLogicalSize is true, return logical size (no block alignment)
if useLogicalSize {
if fileInfo.IsDir() {
@@ -63,9 +63,10 @@ type Options struct {
IsRoutineScan bool // whether this is a routine/scheduled scan (vs initial indexing)
// Filesystem info retrieval options
SkipExtendedAttrs bool // Skip hasPreview and other extended attributes
FollowSymlinks bool // Whether to follow symlinks or return symlink info
ShowHidden bool // Whether to include hidden files/directories
SkipExtendedAttrs bool // Skip hasPreview and other extended attributes
FollowSymlinks bool // Whether to follow symlinks or return symlink info
ShowHidden bool // Whether to include hidden files/directories
HideFileExt string // Hide files based on extensions
}
// ScannerInfo is the exposed scanner information for the client
@@ -85,29 +86,36 @@ type Stats struct {
LastIndexedUnix int64 `json:"lastIndexedUnixTime"`
Complexity uint `json:"complexity"`
LastScanned time.Time `json:"lastScanned"`
DiskUsed uint64 `json:"used"`
UsedAlt uint64 `json:"usedAlt"`
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
}
// reduced index is json exposed to the client
type ReducedIndex struct {
Stats
IdxName string `json:"name"`
DiskTotal uint64 `json:"total"`
Status IndexStatus `json:"status"`
Scanners []*ScannerInfo `json:"scanners,omitempty"`
IdxName string `json:"name"`
ReadOnly bool `json:"readOnly"`
Private bool `json:"private"`
Status IndexStatus `json:"status"`
Scanners []*ScannerInfo `json:"scanners,omitempty"`
}
type Index struct {
ReducedIndex
settings.Source `json:"-"`
db *dbsql.IndexDB
previousNumDirs uint64 // Track previous NumDirs to use when scan in progress (computed value is 0)
previousNumFiles uint64 // Track previous NumFiles to use when scan in progress (computed value is 0)
scanners map[string]*Scanner // path -> scanner
mock bool
mu sync.RWMutex
childScanMutex sync.Mutex // Serializes child scanner execution (only one child scanner runs at a time)
settings.Source `json:"-"`
db *dbsql.IndexDB
previousNumDirs uint64 // Track previous NumDirs to use when scan in progress (computed value is 0)
previousNumFiles uint64 // Track previous NumFiles to use when scan in progress (computed value is 0)
scanners map[string]*Scanner // path -> scanner
mock bool
mu sync.RWMutex
activeScannerPath string
schedulerStop chan struct{}
schedulerStopOnce sync.Once
schedulerBatch int // >0: serial pass in progress; suppresses PostScan inside executeScan
// Adaptive scheduler: run one serial scan on process start (then normal slot cadence).
schedulerStartupPassPending bool
// In-memory folder size tracking (not stored in SQLite)
folderSizes map[string]uint64 // path -> size (in-memory only, calculated from children)
folderSizesUnsynced map[string]struct{} // Tracks which folder sizes have changed since last DB sync
@@ -117,6 +125,10 @@ type Index struct {
scanUpdatedPaths map[string]bool // Tracks directories updated by the scan (to distinguish from API updates)
// WebDAV lock system for this source (isolated per source)
WebdavLock webdav.LockSystem
// Adaptive scheduler: shared slot map (UTC unix seconds -> scanners). Only used when IndexingInterval == 0.
scheduleSlotsMu sync.Mutex
scheduleSlots map[int64][]*Scanner
}
var (
@@ -304,18 +316,14 @@ func (idx *Index) getStatusUnlocked() IndexStatus {
}
allScannedAtLeastOnce := true
anyScannerActive := false
for _, scanner := range idx.scanners {
if scanner.lastScanned.IsZero() {
allScannedAtLeastOnce = false
}
if scanner.isScanning {
anyScannerActive = true
}
}
if anyScannerActive || idx.getActiveScannerPathUnlocked() != "" {
if idx.getActiveScannerPathUnlocked() != "" {
return INDEXING
} else if allScannedAtLeastOnce {
return READY
@@ -356,11 +364,7 @@ func StopAllScanners() {
indexesMutex.Lock()
defer indexesMutex.Unlock()
for _, idx := range indexes {
idx.mu.Lock()
for _, scanner := range idx.scanners {
scanner.stop()
}
idx.mu.Unlock()
idx.stopScheduler()
}
}
@@ -401,7 +405,7 @@ func Initialize(source *settings.Source, mock bool, isNewDb bool) {
}
indexes[newIndex.Name] = &newIndex
indexesMutex.Unlock()
if !newIndex.Config.DisableIndexing {
if !newIndex.Config.ResolvedRules.IndexingDisabled {
logger.Infof("initializing index: [%v]", newIndex.Name)
// Start multi-scanner system (each scanner will do its own initial scan)
// Pass isNewDb flag so setupMultiScanner knows whether to load persisted complexity
@@ -429,9 +433,10 @@ type FileInfoRequest struct {
IndexPath string
FollowSymlinks bool
ShowHidden bool
Expand bool // get child items for directories
IsRoutineScan bool // scanner vs API call
SkipExtendedAttrs bool // whether to skip extended attributes
HideFileExt string // Hide files based on their extension
Expand bool // get child items for directories
IsRoutineScan bool // scanner vs API call
SkipExtendedAttrs bool // whether to skip extended attributes
}
// resolvePathContext resolves all path characteristics in a SINGLE stat call
@@ -535,7 +540,7 @@ func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
// getFileInfoFromContext returns info for a single file
func (idx *Index) getFileInfoFromContext(ctx *PathContext, isIndexable bool) (*iteminfo.FileInfo, error) {
size := idx.getFileSizeForDisplay(ctx.FileInfo, ctx.RealPath)
size := idx.getFileSizeForDisplay(ctx.FileInfo)
fileInfo := &iteminfo.FileInfo{
Path: ctx.IndexPath,
ItemInfo: iteminfo.ItemInfo{
@@ -568,6 +573,7 @@ func (idx *Index) getDirInfoFromContext(ctx *PathContext, isViewable, isIndexabl
SkipExtendedAttrs: req.SkipExtendedAttrs || !isIndexable,
FollowSymlinks: req.FollowSymlinks,
ShowHidden: req.ShowHidden,
HideFileExt: req.HideFileExt,
}
return idx.GetDirInfoCore(dir, ctx.FileInfo, ctx.IndexPath, opts, nil)
@@ -734,50 +740,26 @@ func (idx *Index) GetFsInfoViewableOnly(adjustedPath string, followSymlinks bool
})
}
// fetchExtendedAttributes fetches hasPreview for the current directory and batch fetches for subdirectories
func (idx *Index) fetchExtendedAttributes(indexPath string, files []os.FileInfo, opts Options) (bool, map[string]bool) {
hasPreview := false
// fetchExtendedAttributes loads indexed subfolder has_preview for the current directory from GetMetadataInfo (database).
// The listed directory's own HasPreview is derived only in GetDirInfoCore from children (never from a stale parent DB row).
func (idx *Index) fetchExtendedAttributes(indexPath string, opts Options) map[string]bool {
subdirHasPreviewMap := make(map[string]bool)
if opts.SkipExtendedAttrs || opts.Recursive {
return hasPreview, subdirHasPreviewMap
return subdirHasPreviewMap
}
// Fetch hasPreview for current directory
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, true)
if exists {
hasPreview = realDirInfo.HasPreview
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, false)
if !exists {
return subdirHasPreviewMap
}
// Batch fetch hasPreview for all subdirectories
var subdirPaths []string
for _, file := range files {
if !iteminfo.IsDirectory(file) {
continue
}
baseName := file.Name()
if indexPath == "/" {
if !idx.shouldInclude(baseName) {
continue
}
}
if omitList[baseName] {
continue
}
dirPath := indexPath + baseName
childIndexPath := utils.AddTrailingSlashIfNotExists(dirPath)
subdirPaths = append(subdirPaths, childIndexPath)
for _, folder := range realDirInfo.Folders {
key := utils.AddTrailingSlashIfNotExists(indexPath + folder.Name)
subdirHasPreviewMap[key] = folder.HasPreview
}
if len(subdirPaths) > 0 {
var err error
subdirHasPreviewMap, err = idx.db.GetHasPreviewBatch(idx.Name, subdirPaths)
if err != nil {
subdirHasPreviewMap = make(map[string]bool)
}
}
return hasPreview, subdirHasPreviewMap
return subdirHasPreviewMap
}
// processDirectoryItem processes a directory item and returns the itemInfo, size, and whether it should be counted
@@ -825,11 +807,7 @@ func (idx *Index) processDirectoryItem(file os.FileInfo, indexPath string, subdi
if !opts.SkipExtendedAttrs && subdirHasPreviewMap != nil {
if hasPreviewValue, exists := subdirHasPreviewMap[childIndexPath]; exists {
itemInfo.HasPreview = hasPreviewValue
} else {
itemInfo.HasPreview = false
}
} else {
itemInfo.HasPreview = false
}
return itemInfo, itemInfo.Size, true
@@ -851,7 +829,7 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
var size uint64
var shouldCountSize bool
if !opts.Recursive && scanner == nil {
size = uint64(idx.getFileSizeForDisplay(file, fullCombined))
size = uint64(idx.getFileSizeForDisplay(file))
shouldCountSize = true
} else {
size, shouldCountSize = idx.handleFile(file, indexPath, fullCombined, opts.IsRoutineScan, scanner)
@@ -862,7 +840,7 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
bubblesUpHasPreview := false
if !opts.SkipExtendedAttrs {
usedCachedPreview := false
if !idx.Config.DisableIndexing && opts.Recursive {
if !idx.Config.ResolvedRules.IndexingDisabled && opts.Recursive {
simpleType := strings.Split(itemInfo.Type, "/")[0]
if simpleType == "audio" {
previousInfo, exists := idx.GetReducedMetadata(fullCombined, false)
@@ -875,9 +853,7 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
if !usedCachedPreview {
setFilePreviewFlags(itemInfo, fullCombined)
}
if itemInfo.HasPreview && iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo) {
bubblesUpHasPreview = true
}
bubblesUpHasPreview = iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo)
} else {
itemInfo.HasPreview = false
}
@@ -894,8 +870,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
if err != nil {
return nil, err
}
// Fetch extended attributes (hasPreview for current dir and subdirs)
hasPreview, subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, files, opts)
subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, opts)
baseName := filepath.Base(indexPath)
if indexPath == "/" {
@@ -906,6 +881,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
fileInfos := []iteminfo.ExtendedItemInfo{}
dirInfos := []iteminfo.ItemInfo{}
processedCount := 0
hasPreview := false
for _, file := range files {
subfileBaseName := file.Name()
@@ -960,12 +936,12 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
}
} else {
itemInfo, size, shouldCount, bubblesUp := idx.processFileItem(file, indexPath, opts, scanner)
if shouldCount {
totalSize += size
}
if bubblesUp {
hasPreview = true
}
if shouldCount {
totalSize += size
}
fileInfos = append(fileInfos, iteminfo.ExtendedItemInfo{ItemInfo: *itemInfo})
if opts.IsRoutineScan {
idx.incrementScannerFiles()
@@ -1054,6 +1030,10 @@ func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
// Use recursive=true for copy/move operations to capture entire tree
// Use recursive=false for simple metadata updates (like after file edits)
func (idx *Index) RefreshDirectory(indexPath string, recursive bool) error {
if idx.Config.ResolvedRules.IndexingDisabled {
return nil
}
if !strings.HasSuffix(indexPath, "/") {
indexPath = indexPath + "/"
}
@@ -1132,6 +1112,7 @@ func (idx *Index) updateFolderSizeAndParents(path string, newSize uint64, previo
if parentDir == "" {
break // Reached the top
}
// Keys in folderSizes match indexing paths (trailing slash for non-root dirs; root stays "/").
if parentDir != "/" {
parentDir = utils.AddTrailingSlashIfNotExists(parentDir)
}
@@ -1158,22 +1139,6 @@ func (idx *Index) updateFolderSizeAndParents(path string, newSize uint64, previo
}
}
// SetFolderSize sets the size for a directory in the in-memory map
func (idx *Index) SetFolderSize(path string, newSize uint64) {
idx.folderSizesMu.Lock()
defer idx.folderSizesMu.Unlock()
idx.folderSizes[path] = newSize
idx.folderSizesUnsynced[path] = struct{}{} // Mark as changed for next DB sync
}
// GetFolderSize retrieves the size for a directory from the in-memory map
func (idx *Index) GetFolderSize(path string) (uint64, bool) {
idx.folderSizesMu.RLock()
defer idx.folderSizesMu.RUnlock()
size, exists := idx.folderSizes[path]
return size, exists
}
// GetFolderSizeForDisplay retrieves folder size with appropriate formatting based on config
func (idx *Index) GetFolderSizeForDisplay(path string) int64 {
size, exists := idx.GetFolderSize(path)
@@ -1194,28 +1159,14 @@ func (idx *Index) GetFolderSizeForDisplay(path string) int64 {
}
// getFileSizeForDisplay returns file size with appropriate calculation based on config
func (idx *Index) getFileSizeForDisplay(file os.FileInfo, realPath string) int64 {
func (idx *Index) getFileSizeForDisplay(file os.FileInfo) int64 {
if idx.Config.UseLogicalSize {
// Logical size mode: return actual bytes
return file.Size()
}
// Disk usage mode: use getDiskUsage helper
return getDiskUsage(file, realPath, false)
}
// RecursiveUpdateDirSizes is now a wrapper for backwards compatibility
// This maintains API compatibility while using the new optimized approach
func (idx *Index) RecursiveUpdateDirSizes(path string, previousSize uint64) {
currentSize, exists := idx.GetFolderSize(path)
if !exists {
logger.Debugf("[FOLDER_SIZE] Path %s not found in folderSizes map", path)
return
}
if currentSize != previousSize {
idx.updateFolderSizeAndParents(path, currentSize, previousSize, false) // updateTarget=false, only update parents
}
return getDiskUsage(file, false)
}
// SyncFolderSizesToDB syncs in-memory folder sizes to the database after a scan completes
@@ -1405,7 +1356,7 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHid
// IsViewableWithParentCheck checks if a path is viewable by walking up the directory tree
// Used for API requests where we need to check inherited rules from parent folders
func (idx *Index) IsViewableWithParentCheck(isDir bool, indexPath string, isSymlink bool, isHidden bool) bool {
if idx.Config.DisableIndexing || idx.Config.ResolvedRules.NoRules {
if idx.Config.ResolvedRules.IndexingDisabled || idx.Config.ResolvedRules.NoRules {
return true
}
@@ -1428,7 +1379,7 @@ func (idx *Index) IsViewableWithParentCheck(isDir bool, indexPath string, isSyml
// ShouldSkip checks if a path should be skipped from indexing
// isRoutineScan: true for scanner (no parent checks), false for API (check parents)
func (idx *Index) ShouldSkip(isDir bool, adjustedPath string, isHidden bool, isSymlink bool, isRoutineScan bool) bool {
if idx.Config.DisableIndexing {
if idx.Config.ResolvedRules.IndexingDisabled {
return true
}
rules := idx.Config.ResolvedRules
@@ -1532,18 +1483,15 @@ func (idx *Index) IsNeverWatchPath(adjustedPath string) bool {
return exists
}
type DiskUsage struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
}
func (idx *Index) SetUsage(totalBytes uint64) {
func (idx *Index) SetUsage(totalDiskSize, partitionUsed, UsedAsIndexed uint64) {
if settings.Config.Frontend.DisableUsedPercentage {
return
}
idx.mu.Lock()
defer idx.mu.Unlock()
idx.DiskTotal = totalBytes
idx.DiskTotal = totalDiskSize
idx.UsedAsIndexed = UsedAsIndexed
idx.UsedDisk = partitionUsed
}
func (idx *Index) SetStatus(status IndexStatus) error {
@@ -1563,32 +1511,7 @@ func (idx *Index) getActiveScannerPath() string {
// getActiveScannerPathUnlocked returns the path of the currently active scanner, or empty string if none
func (idx *Index) getActiveScannerPathUnlocked() string {
for path, scanner := range idx.scanners {
if scanner.isScanning {
return path
}
}
return ""
}
// getRunningScannerCount returns the number of scanners currently running
// Assumes mutex is NOT held - will acquire its own lock
func (idx *Index) getRunningScannerCount() int {
idx.mu.RLock()
defer idx.mu.RUnlock()
return idx.getRunningScannerCountUnlocked()
}
// getRunningScannerCountUnlocked returns the number of scanners currently running
// Assumes mutex IS already held (RLock or Lock)
func (idx *Index) getRunningScannerCountUnlocked() int {
count := 0
for _, scanner := range idx.scanners {
if scanner.isScanning {
count++
}
}
return count
return idx.activeScannerPath
}
// input should be non-index path.
@@ -1628,14 +1551,29 @@ func (idx *Index) shouldInclude(baseName string) bool {
return false
}
// Save persists the index and scanner information to the database
// useAdaptiveScheduling is true when the index uses tiered/aligned scheduling (IndexingInterval == 0).
func (idx *Index) useAdaptiveScheduling() bool {
return idx.Config.IndexingInterval == 0
}
// Save persists the index and scanner information to the database and notifies clients via SSE.
func (idx *Index) Save() error {
if err := idx.writePersistedIndexInfo(); err != nil {
return err
}
if err := idx.SendSourceUpdateEvent(); err != nil {
logger.Errorf("[%s] Failed to send source update event: %v", idx.Name, err)
}
return nil
}
// writePersistedIndexInfo writes scanners, stats, and disk totals to Bolt (no SSE broadcast).
func (idx *Index) writePersistedIndexInfo() error {
if indexingStorage == nil {
return nil // No storage available, skip persistence
return nil
}
idx.mu.RLock()
// Collect scanner information
scanners := make(map[string]*dbindex.PersistedScannerInfo)
for path, scanner := range idx.scanners {
scanners[path] = &dbindex.PersistedScannerInfo{
@@ -1650,33 +1588,27 @@ func (idx *Index) Save() error {
}
}
// Get current index stats
complexity := idx.getComplexityUnlocked()
numDirs := idx.getNumDirsUnlocked()
numFiles := idx.getNumFilesUnlocked()
usedAsIndexed := idx.UsedAsIndexed
usedDisk := idx.UsedDisk
diskTotal := idx.DiskTotal
idx.mu.RUnlock()
// Create IndexInfo for persistence
info := &dbindex.IndexInfo{
Path: idx.Path, // Use real filesystem path as key
Source: idx.Name,
Complexity: complexity,
NumDirs: numDirs,
NumFiles: numFiles,
Scanners: scanners,
Path: idx.Path,
Source: idx.Name,
Complexity: complexity,
NumDirs: numDirs,
NumFiles: numFiles,
UsedAsIndexed: usedAsIndexed,
UsedDisk: usedDisk,
DiskTotal: diskTotal,
Scanners: scanners,
}
err := indexingStorage.Save(info)
if err != nil {
return err
}
// Send SSE update event after successful save
if err := idx.SendSourceUpdateEvent(); err != nil {
logger.Errorf("[%s] Failed to send source update event: %v", idx.Name, err)
}
return nil
return indexingStorage.Save(info)
}
// Load restores index and scanner information from the database
@@ -1703,6 +1635,9 @@ func (idx *Index) Load() error {
idx.Complexity = info.Complexity
idx.NumDirs = info.NumDirs
idx.NumFiles = info.NumFiles
idx.UsedAsIndexed = info.UsedAsIndexed
idx.UsedDisk = info.UsedDisk
idx.DiskTotal = info.DiskTotal
// Restore scanner information (will be applied when scanners are created)
// Store in a temporary map that setupMultiScanner can use
+1 -2
View File
@@ -29,8 +29,7 @@ func setupTestIndex(t *testing.T) (*Index, string, func()) {
Name: "test",
Path: "/mock/path",
Config: settings.SourceConfig{
DisableIndexing: false,
UseLogicalSize: true,
UseLogicalSize: true,
},
},
mock: true, // Enable mock mode
+143 -160
View File
@@ -1,6 +1,7 @@
package indexing
import (
"errors"
"os"
"path/filepath"
"strings"
@@ -15,21 +16,24 @@ import (
type Scanner struct {
scanPath string
// Adaptive scheduling (see Index.useAdaptiveScheduling)
currentSchedule int
smartModifier time.Duration
complexity uint
fullScanCounter int
nextSleepTime time.Duration
calendarSlotSec int64 // UTC unix second in idx.scheduleSlots; 0 if not enrolled
nextRun time.Time // next slot time; zero if not scheduled / due immediately
fullScanCounter int // quick vs full cadence per folder
filesChanged bool
lastScanned time.Time
quickScanTime int
fullScanTime int
statsMu sync.RWMutex
scanStartTime int64
isScanning bool // True when scanner is actively scanning or waiting for mutex
numDirs uint64
numFiles uint64
// Stats (API and persistence)
complexity uint
lastScanned time.Time
quickScanTime int
fullScanTime int
numDirs uint64
numFiles uint64
statsMu sync.RWMutex
// Active scan session
scanStartTime int64
// Per-scanner state (not shared with other scanners)
processedInodes map[uint64]struct{} // Track inodes to detect hardlinks
@@ -37,9 +41,6 @@ type Scanner struct {
batchItems []*iteminfo.FileInfo // Accumulates items for bulk insert
idx *Index
stopChan chan struct{}
stopOnce sync.Once // Ensures stopChan is only closed once
}
func (s *Scanner) withStatsLock(fn func()) {
@@ -54,72 +55,51 @@ func (s *Scanner) withStatsRLock(fn func()) {
fn()
}
// start begins the scanner's main loop
func (s *Scanner) start() {
// Wait a bit to stagger child scanner initial scans (root goes first)
if s.scanPath != "/" {
time.Sleep(500 * time.Millisecond)
}
s.tryAcquireAndScan()
for {
// Check if directory still exists (for non-root scanners)
if s.scanPath != "/" && !s.directoryExists() {
logger.Debugf("Scanner [%s] stopping: directory no longer exists", s.scanPath)
s.removeSelf()
return
}
// Use the sleep time calculated by updateSchedule() (based on OLD schedule before increment)
sleepTime := s.nextSleepTime
if sleepTime == 0 {
// Fallback for first run or if not set
sleepTime = s.calculateSleepTime()
}
select {
case <-s.stopChan:
return
case <-time.After(sleepTime):
s.tryAcquireAndScan()
}
}
}
// tryAcquireAndScan attempts to acquire the global scan mutex and run a scan
func (s *Scanner) tryAcquireAndScan() {
// Ensure isScanning is cleared even if we panic
// executeScan runs one scan for this scanner (invoked only from the per-index scheduler).
func (s *Scanner) executeScan() {
defer func() {
if r := recover(); r != nil {
logger.Errorf("[%s] Scanner panic recovered: %v", s.scanPath, r)
}
s.idx.mu.Lock()
s.isScanning = false
s.idx.activeScannerPath = ""
batch := s.idx.schedulerBatch > 0
s.idx.mu.Unlock()
err := s.idx.PostScan()
if err != nil {
logger.Errorf("Failed to post scan: %v", err)
if !batch {
err := s.idx.PostScan()
if err != nil {
logger.Errorf("Failed to post scan: %v", err)
}
}
}()
// Set isScanning flag for all scanners
if s.scanPath != "/" && !s.directoryExists() {
s.handlePathGone("pre-scan check")
return
}
s.prepareScanState()
s.idx.mu.Lock()
s.isScanning = true
s.idx.activeScannerPath = s.scanPath
s.idx.mu.Unlock()
// Root scanner can run independently since it's non-recursive
// Child scanners must acquire mutex to prevent concurrent child scans
if s.scanPath != "/" {
s.idx.childScanMutex.Lock()
defer s.idx.childScanMutex.Unlock()
}
quick := s.fullScanCounter > 0 && s.fullScanCounter < 5
s.fullScanCounter++
if s.fullScanCounter >= 5 {
s.fullScanCounter = 0
// Cadence per scanner path: full when counter==0, then four quick scans, then wrap (5 runs per cycle).
var pre int
var quick bool
s.withStatsLock(func() {
pre = s.fullScanCounter
quick = pre > 0 && pre < 5
s.fullScanCounter++
if s.fullScanCounter >= 5 {
s.fullScanCounter = 0
}
})
scanMode := "full"
if quick {
scanMode = "quick"
}
logger.Debugf("[%s] scanner [%s] starting %s scan (full/quick cadence step %d of 5)", s.idx.Name, s.scanPath, scanMode, pre+1)
s.runIndexing(quick)
s.updateSchedule()
@@ -195,13 +175,14 @@ func (s *Scanner) runFullScanRoot() {
s.purgeStaleEntries(true, false) // isRoot=true, isQuickScan=false
s.syncStatsWithDB()
if s.processedInodes != nil {
s.processedInodes = nil
s.foundHardLinks = nil
}
s.resetHardlinkTracking()
s.idx.mu.Lock()
s.idx.scanUpdatedPaths = make(map[string]bool)
if s.idx.scanUpdatedPaths == nil {
s.idx.scanUpdatedPaths = make(map[string]bool)
} else {
clear(s.idx.scanUpdatedPaths)
}
s.idx.mu.Unlock()
}
@@ -299,6 +280,10 @@ func (s *Scanner) runFullScanChild() {
_, _, err := s.idx.indexDirectory(s.scanPath, config, s)
if err != nil {
if isPathGone(err) {
s.handlePathGone("full scan")
return
}
logger.Errorf("Scanner [%s] error: %v", s.scanPath, err)
} else if config.Recursive && config.IsRoutineScan {
s.idx.mu.Lock()
@@ -316,10 +301,7 @@ func (s *Scanner) runFullScanChild() {
}
}
if s.processedInodes != nil {
s.processedInodes = nil
s.foundHardLinks = nil
}
s.resetHardlinkTracking()
s.idx.mu.Lock()
for path := range s.idx.scanUpdatedPaths {
@@ -339,6 +321,9 @@ func (s *Scanner) runQuickScanChild() {
// Get folders in this scanner's scope from folderSizes map
s.idx.folderSizesMu.RLock()
var foldersToCheck []string
// Scanner root must be modtime-checked: the loop below skips path == scanPath, but only
// checkFolderModtime compares modtime to lastScanned and re-reads the directory for new children.
foldersToCheck = append(foldersToCheck, s.scanPath)
for path := range s.idx.folderSizes {
// Child scanner: check all paths under its scope
if strings.HasPrefix(path, s.scanPath) && path != s.scanPath {
@@ -350,25 +335,6 @@ func (s *Scanner) runQuickScanChild() {
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
// Update the scanner's own root folder to prevent it from being purged
// (child folders are updated via checkFolderModtime, but the scanner's root needs explicit update)
fullScanPath := filepath.Join(s.idx.Path, s.scanPath)
if info, err := os.Stat(fullScanPath); err == nil {
rootFolderInfo := &iteminfo.FileInfo{
Path: s.scanPath,
ItemInfo: iteminfo.ItemInfo{
Name: filepath.Base(strings.TrimSuffix(s.scanPath, "/")),
Size: 0, // Folder size managed in-memory via folderSizes map
ModTime: info.ModTime(),
Type: "directory",
Hidden: false,
HasPreview: false,
},
}
// Add to batch for bulk update
s.batchItems = append(s.batchItems, rootFolderInfo)
}
// Track stats
unchangedCount := 0
changedCount := 0
@@ -416,11 +382,9 @@ func (s *Scanner) checkForNewChildDirectories() {
}
s.idx.mu.RUnlock()
for path, scanner := range existingScanners {
for path := range existingScanners {
if !currentDirsMap[path] {
logger.Debugf("Directory [%s] no longer exists, stopping scanner", path)
scanner.stop()
// Remove from map immediately to prevent stale scanner references
logger.Debugf("Directory [%s] no longer exists, removing scanner", path)
s.idx.mu.Lock()
delete(s.idx.scanners, path)
s.idx.mu.Unlock()
@@ -437,8 +401,6 @@ func (s *Scanner) checkForNewChildDirectories() {
s.idx.mu.Lock()
s.idx.scanners[dirPath] = newScanner
s.idx.mu.Unlock()
go newScanner.start()
}
}
}
@@ -486,85 +448,99 @@ func (s *Scanner) getTopLevelDirs() []string {
return dirs
}
// calculateSleepTime determines how long to wait before the next scan
func (s *Scanner) calculateSleepTime() time.Duration {
var sleepTime time.Duration
s.withStatsRLock(func() {
sleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
})
if s.idx.Config.IndexingInterval > 0 {
sleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
}
return sleepTime
}
// updateSchedule adjusts the scanner's schedule based on whether files changed
// updateSchedule adjusts the scanner's schedule based on whether files changed (adaptive mode only).
func (s *Scanner) updateSchedule() {
s.statsMu.Lock()
defer s.statsMu.Unlock()
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
if s.idx.Config.IndexingInterval > 0 {
s.nextSleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
if !s.idx.useAdaptiveScheduling() {
return
}
s.statsMu.Lock()
// Adjust schedule based on file changes
if s.filesChanged {
logger.Debugf("Scanner [%s] detected changes, adjusting schedule", s.scanPath)
// Move to at least schedule index 3 or reduce interval
// Floor at 40-minute tier (index 3)
if s.currentSchedule > 3 {
s.currentSchedule = 3
} else if s.currentSchedule > 0 {
s.currentSchedule--
}
} else {
// Increment toward the longest interval if no changes
if s.currentSchedule < len(scanSchedule)-1 {
if s.currentSchedule < len(scanScheduleTiers)-1 {
s.currentSchedule++
}
}
// Cap simple complexity (1) at schedule 4
// Don't apply this cap if complexity is still unknown (0)
if s.complexity == 1 && s.currentSchedule > 4 {
s.currentSchedule = 4
}
minTier, maxTier := scheduleTierBoundsForComplexity(s.complexity)
s.currentSchedule = utils.Clamp(s.currentSchedule, minTier, maxTier)
// Next slot must use the tier *after* adjustment; using the pre-change interval ignored
// speed-ups on filesChanged and could schedule the next run in the far future.
intervalForNext := scanScheduleDuration(s.currentSchedule)
lastScanned := s.lastScanned
s.statsMu.Unlock()
// Ensure currentSchedule stays within bounds
if s.currentSchedule < 0 {
s.currentSchedule = 0
} else if s.currentSchedule >= len(scanSchedule) {
s.currentSchedule = len(scanSchedule) - 1
}
now := time.Now()
nextRun := computeNextSlotTime(lastScanned, now, intervalForNext)
// Persist index after schedule update (happens after each scan)
if err := s.idx.Save(); err != nil {
logger.Errorf("Failed to save index after schedule update: %v", err)
}
s.statsMu.Lock()
s.nextRun = nextRun
s.statsMu.Unlock()
s.idx.registerScannerNextRun(s, nextRun)
}
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
// 0: unknown
func (s *Scanner) updateComplexity() {
s.statsMu.Lock()
defer s.statsMu.Unlock()
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
s.statsMu.Unlock()
// Set smartModifier based on complexity level
if modifier, ok := complexityModifier[s.complexity]; ok {
s.smartModifier = modifier
} else {
s.smartModifier = 0
}
// Persist index after complexity update (happens after full scans)
// Persist after full scans — release statsMu before Save (same ordering as updateSchedule).
if err := s.idx.Save(); err != nil {
logger.Errorf("Failed to save index after complexity update: %v", err)
}
}
// ensureScanUpdatedPaths initializes scan path tracking for the current scan session.
func (idx *Index) ensureScanUpdatedPaths() {
idx.mu.Lock()
defer idx.mu.Unlock()
if idx.scanUpdatedPaths == nil {
idx.scanUpdatedPaths = make(map[string]bool)
}
}
// prepareScanState initializes per-scan maps. Quick scans previously skipped this and could
// panic with "assignment to entry in nil map" when writing scanUpdatedPaths or hardlink tracking.
func (s *Scanner) prepareScanState() {
s.idx.ensureScanUpdatedPaths()
if s.processedInodes == nil {
s.processedInodes = make(map[uint64]struct{})
}
if s.foundHardLinks == nil {
s.foundHardLinks = make(map[string]uint64)
}
}
func (s *Scanner) resetHardlinkTracking() {
s.processedInodes = nil
s.foundHardLinks = nil
}
func isPathGone(err error) bool {
return err != nil && (os.IsNotExist(err) || errors.Is(err, os.ErrNotExist))
}
// handlePathGone removes a child scanner whose root directory was deleted.
func (s *Scanner) handlePathGone(context string) {
if s.scanPath == "/" {
return
}
logger.Infof("Scanner [%s] stopping (%s): directory no longer exists", s.scanPath, context)
s.removeSelf()
}
// directoryExists checks if the scanner's directory still exists
func (s *Scanner) directoryExists() bool {
realPath := strings.TrimRight(s.idx.Path, "/") + s.scanPath
@@ -576,6 +552,10 @@ func (s *Scanner) directoryExists() bool {
// removeSelf removes this scanner from the index's scanner map
func (s *Scanner) removeSelf() {
s.idx.scheduleSlotsMu.Lock()
s.idx.removeScannerFromSlotLocked(s)
s.idx.scheduleSlotsMu.Unlock()
s.idx.mu.Lock()
defer s.idx.mu.Unlock()
@@ -583,14 +563,6 @@ func (s *Scanner) removeSelf() {
logger.Infof("Removed scanner [%s] from active scanners", s.scanPath)
}
// stop gracefully stops the scanner
// Uses sync.Once to ensure the channel is only closed once, preventing panic from "close of closed channel"
func (s *Scanner) stop() {
s.stopOnce.Do(func() {
close(s.stopChan)
})
}
func (s *Scanner) syncStatsWithDB() {
var dirs, files uint64
var err error
@@ -624,7 +596,10 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
info, err := os.Stat(realPath)
if err != nil {
if os.IsNotExist(err) {
// Folder was deleted - will be caught by purge
if folderPath == s.scanPath {
s.handlePathGone("quick scan")
}
// Subfolder deleted — stale entries are removed by purge
return false, nil
}
logger.Errorf("[QUICK_SCAN] Error stating %s: %v", folderPath, err)
@@ -664,6 +639,9 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
// Track this directory as having been updated (for file purge logic)
s.idx.mu.Lock()
if s.idx.scanUpdatedPaths == nil {
s.idx.scanUpdatedPaths = make(map[string]bool)
}
s.idx.scanUpdatedPaths[folderPath] = true
s.idx.mu.Unlock()
@@ -672,11 +650,16 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
// Modtime unchanged - update last_updated to prevent folder from being purged
// Note: we DON'T add this folder to scanUpdatedPaths, so Phase 2 won't check its files
// Use cached aggregate size: BulkInsertItems overwrites DB size;0 would corrupt LoadFolderSizes on restart.
var sizeForDB int64
if cached, ok := s.idx.GetFolderSize(folderPath); ok {
sizeForDB = int64(cached)
}
fileInfo := &iteminfo.FileInfo{
Path: folderPath,
ItemInfo: iteminfo.ItemInfo{
Name: filepath.Base(strings.TrimSuffix(folderPath, "/")),
Size: 0, // Folder size is managed in-memory via folderSizes map
Size: sizeForDB,
ModTime: info.ModTime(),
Type: "directory",
Hidden: false,
+35
View File
@@ -0,0 +1,35 @@
package indexing
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
func TestPrepareScanStateInitializesMaps(t *testing.T) {
idx := &Index{
Source: settings.Source{Name: "test", Path: t.TempDir()},
}
s := &Scanner{scanPath: "/music/", idx: idx}
s.prepareScanState()
idx.mu.Lock()
idx.scanUpdatedPaths["/music/"] = true
idx.mu.Unlock()
if s.processedInodes == nil {
t.Fatal("processedInodes should be initialized")
}
s.processedInodes[1] = struct{}{}
}
func TestIsPathGone(t *testing.T) {
if isPathGone(nil) {
t.Fatal("nil error should not be path gone")
}
if !isPathGone(os.ErrNotExist) {
t.Fatal("os.ErrNotExist should be path gone")
}
}
+76 -50
View File
@@ -5,38 +5,57 @@ import (
"strconv"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
indexingdb "github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/events"
"github.com/gtsteffaniak/go-logger/logger"
)
// schedule in minutes
var scanSchedule = map[int]time.Duration{
0: 5 * time.Minute, // 5 minute scan
1: 10 * time.Minute,
2: 20 * time.Minute,
3: 40 * time.Minute, // schedule index 3 for file changes
4: 1 * time.Hour,
5: 2 * time.Hour,
6: 3 * time.Hour,
7: 4 * time.Hour,
8: 8 * time.Hour,
9: 12 * time.Hour,
// scanScheduleTiers is the only set of adaptive scan intervals (index = currentSchedule tier).
// Fastest tier is 5 minutes so consecutive runs for a path are at least ~5 minutes apart on the grid.
//
// On filesChanged: if current tier is slower than 40 minutes (index > 3), jump to tier 3; otherwise
// step one tier faster (index--). High-complexity scanners are clamped to scheduleTierBoundsForComplexity.
var scanScheduleTiers = []time.Duration{
5 * time.Minute,
10 * time.Minute,
20 * time.Minute,
40 * time.Minute,
1 * time.Hour,
2 * time.Hour,
3 * time.Hour,
4 * time.Hour,
8 * time.Hour,
12 * time.Hour,
}
// complexityModifier defines time adjustments based on complexity level (0-10)
var complexityModifier = map[uint]time.Duration{
0: 0 * time.Minute, // unknown: no modifier
1: -4 * time.Minute, // simple: scan more frequently
2: -2 * time.Minute, // normal (lightest)
3: -1 * time.Minute,
4: 0 * time.Minute, // baseline normal
5: 1 * time.Minute,
6: 2 * time.Minute, // normal (heaviest)
7: 4 * time.Minute, // complex (lightest)
8: 8 * time.Minute,
9: 12 * time.Minute,
10: 16 * time.Minute, // highlyComplex: scan less frequently
func scanScheduleDuration(tier int) time.Duration {
return scanScheduleTiers[utils.Clamp(tier, 0, len(scanScheduleTiers)-1)]
}
// scheduleTierBoundsForComplexity limits how aggressive or idle a scanner may be scheduled.
// minTier: fastest allowed interval (low index); maxTier: slowest allowed (high index).
// Complexity 0 = unknown (e.g. before first full scan): no bounds. Complexity 1: never slower than 1h.
func scheduleTierBoundsForComplexity(c uint) (minTier, maxTier int) {
maxTier = len(scanScheduleTiers) - 1
minTier = 0
if c == 1 {
maxTier = 4 // index 4 = 1 hour; trivial trees should not stretch to multi-hour tiers
}
switch {
case c >= 10:
minTier = 4 // at least 1h between runs
case c >= 8:
minTier = 3 // at least 40m
case c >= 6:
minTier = 2 // at least 20m
case c >= 4:
minTier = 1 // at least 10m
}
if minTier > maxTier {
minTier = maxTier
}
return minTier, maxTier
}
// calculateTimeScore returns a 1-10 score based on scan time
@@ -172,13 +191,11 @@ func (idx *Index) incrementScannerFiles() {
}
}
func (idx *Index) PreScan() error {
return idx.SetStatus(INDEXING)
}
func (idx *Index) PostScan() error {
// Only do expensive operations when ALL scanners are done
if idx.getRunningScannerCount() == 0 {
idx.mu.RLock()
idle := idx.activeScannerPath == ""
idx.mu.RUnlock()
if idle {
// All scanners completed
err := idx.db.ShrinkMemory()
if err != nil {
@@ -205,7 +222,9 @@ func (idx *Index) SendSourceUpdateEvent() error {
if idx.mock {
return nil
}
reducedIndex, err := GetIndexInfo(idx.Name, true)
// Avoid force=true: otherwise GetIndexInfo invalidates DiskUsageCache, recomputes, and writePersistedIndexInfo
// nests under Save recursion; false uses cached probe / current idx totals for the broadcast payload.
reducedIndex, err := GetIndexInfo(idx.Name, false)
if err != nil {
logger.Errorf("[%s] Error getting index info: %v", idx.Name, err)
return err
@@ -226,21 +245,12 @@ func (idx *Index) SendSourceUpdateEvent() error {
// setupMultiScanner creates and starts the multi-scanner system
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
func (idx *Index) setupMultiScanner(isNewDb bool) {
// Load persisted index and scanner information
// Load persisted index-level stats from Bolt (complexity, numDirs/Files, used/usedAlt/total, etc.)
// Scanner-level stats below use a second GetByPath only for the Scanners map.
if err := idx.Load(); err != nil {
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
}
// Load existing folder sizes from database to avoid marking everything as dirty on startup
existingSizes, err := idx.db.LoadFolderSizes(idx.Name)
if err != nil {
logger.Errorf("[INIT] Failed to load existing folder sizes for [%s]: %v", idx.Name, err)
} else {
idx.folderSizesMu.Lock()
idx.folderSizes = existingSizes
idx.folderSizesMu.Unlock()
}
idx.mu.Lock()
idx.scanners = make(map[string]*Scanner)
idx.mu.Unlock()
@@ -256,23 +266,27 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
// Create and start root scanner
rootScanner := idx.createScanner("/")
adaptive := idx.useAdaptiveScheduling()
if persistedScanners != nil {
if rootInfo, ok := persistedScanners["/"]; ok {
rootScanner.withStatsLock(func() {
rootScanner.complexity = rootInfo.Complexity
rootScanner.currentSchedule = rootInfo.CurrentSchedule
if adaptive {
rootScanner.currentSchedule = utils.Clamp(rootInfo.CurrentSchedule, 0, len(scanScheduleTiers)-1)
}
rootScanner.quickScanTime = rootInfo.QuickScanTime
rootScanner.fullScanTime = rootInfo.FullScanTime
rootScanner.numDirs = rootInfo.NumDirs
rootScanner.numFiles = rootInfo.NumFiles
rootScanner.lastScanned = rootInfo.LastScanned
rootScanner.fullScanCounter = utils.Clamp(rootInfo.FullScanCounter, 0, 4)
})
}
}
idx.mu.Lock()
idx.scanners["/"] = rootScanner
idx.schedulerStop = make(chan struct{})
idx.mu.Unlock()
go rootScanner.start()
// Discover existing top-level directories (root scanner will create scanners for new ones dynamically)
topLevelDirs := rootScanner.getTopLevelDirs()
@@ -286,12 +300,15 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
if childInfo, ok := persistedScanners[dirPath]; ok {
childScanner.withStatsLock(func() {
childScanner.complexity = childInfo.Complexity
childScanner.currentSchedule = childInfo.CurrentSchedule
if adaptive {
childScanner.currentSchedule = utils.Clamp(childInfo.CurrentSchedule, 0, len(scanScheduleTiers)-1)
}
childScanner.quickScanTime = childInfo.QuickScanTime
childScanner.fullScanTime = childInfo.FullScanTime
childScanner.numDirs = childInfo.NumDirs
childScanner.numFiles = childInfo.NumFiles
childScanner.lastScanned = childInfo.LastScanned
childScanner.fullScanCounter = utils.Clamp(childInfo.FullScanCounter, 0, 4)
})
}
}
@@ -299,10 +316,17 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
idx.mu.Lock()
idx.scanners[dirPath] = childScanner
idx.mu.Unlock()
go childScanner.start()
}
if idx.useAdaptiveScheduling() {
idx.mu.Lock()
idx.schedulerStartupPassPending = true
idx.mu.Unlock()
}
idx.restoreScannerNextRuns()
go idx.runIndexScheduler()
logger.Debugf("Created %d scanners for [%v] (1 root + %d children)", len(topLevelDirs)+1, idx.Name, len(topLevelDirs))
}
@@ -311,7 +335,6 @@ func (idx *Index) createScanner(dirPath string) *Scanner {
return &Scanner{
scanPath: dirPath,
idx: idx,
stopChan: make(chan struct{}),
currentSchedule: 0,
fullScanCounter: 0,
complexity: 0, // 0 = unknown until first full scan completes
@@ -345,7 +368,10 @@ func (idx *Index) GetScannerStatus() map[string]interface{} {
scannerInfo["numDirs"] = scanner.numDirs
scannerInfo["numFiles"] = scanner.numFiles
scannerInfo["filesChanged"] = scanner.filesChanged
scannerInfo["smartModifier"] = scanner.smartModifier.String()
scannerInfo["fullScanCounter"] = scanner.fullScanCounter
if !scanner.nextRun.IsZero() {
scannerInfo["nextRun"] = scanner.nextRun.Format(time.RFC3339)
}
})
scannerStats = append(scannerStats, scannerInfo)
}
+359
View File
@@ -0,0 +1,359 @@
package indexing
import (
"sort"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// alignCeilToInterval rounds t up (UTC) to the next boundary where Unix time is a multiple of interval.
func alignCeilToInterval(t time.Time, interval time.Duration) time.Time {
if interval < time.Minute {
interval = time.Minute
}
stepNs := interval.Nanoseconds()
total := t.UTC().UnixNano()
aligned := (total + stepNs - 1) / stepNs * stepNs
return time.Unix(0, aligned).UTC()
}
// computeNextSlotTime returns the next scheduled slot after a scan completes.
// interval must be one of scanScheduleTiers; alignment uses that interval as the grid step.
func computeNextSlotTime(lastScanned, ref time.Time, interval time.Duration) time.Time {
minStep := scanScheduleTiers[0]
if interval < minStep {
interval = minStep
}
minNext := lastScanned.Add(interval)
if minNext.Before(ref) {
minNext = ref
}
return alignCeilToInterval(minNext, interval)
}
func (idx *Index) removeScannerFromSlotLocked(s *Scanner) {
sec := s.calendarSlotSec
if sec == 0 {
return
}
if idx.scheduleSlots == nil {
s.calendarSlotSec = 0
return
}
lst := idx.scheduleSlots[sec]
out := lst[:0]
for _, x := range lst {
if x != s {
out = append(out, x)
}
}
if len(out) == 0 {
delete(idx.scheduleSlots, sec)
} else {
idx.scheduleSlots[sec] = out
}
s.calendarSlotSec = 0
}
// registerScannerNextRun removes the scanner from any previous slot and adds it at the given UTC time.
func (idx *Index) registerScannerNextRun(s *Scanner, when time.Time) {
if !idx.useAdaptiveScheduling() {
return
}
when = when.UTC().Truncate(time.Second)
sec := when.Unix()
idx.scheduleSlotsMu.Lock()
defer idx.scheduleSlotsMu.Unlock()
if idx.scheduleSlots == nil {
idx.scheduleSlots = make(map[int64][]*Scanner)
}
idx.removeScannerFromSlotLocked(s)
idx.scheduleSlots[sec] = append(idx.scheduleSlots[sec], s)
s.calendarSlotSec = sec
}
func (idx *Index) takeDueScannerBatch(now time.Time) []*Scanner {
idx.scheduleSlotsMu.Lock()
defer idx.scheduleSlotsMu.Unlock()
if idx.scheduleSlots == nil {
return nil
}
var dueSec int64 = -1
nowSec := now.Unix()
for sec := range idx.scheduleSlots {
if sec <= nowSec {
if dueSec < 0 || sec < dueSec {
dueSec = sec
}
}
}
if dueSec < 0 {
return nil
}
lst := idx.scheduleSlots[dueSec]
delete(idx.scheduleSlots, dueSec)
for _, s := range lst {
s.calendarSlotSec = 0
}
return lst
}
func (idx *Index) earliestSlotWake(now time.Time) time.Time {
idx.scheduleSlotsMu.Lock()
defer idx.scheduleSlotsMu.Unlock()
if len(idx.scheduleSlots) == 0 {
return now.Add(time.Minute)
}
nowSec := now.Unix()
var earliest int64 = -1
for sec := range idx.scheduleSlots {
if sec <= nowSec {
return now
}
if earliest < 0 || sec < earliest {
earliest = sec
}
}
if earliest < 0 {
return now.Add(time.Minute)
}
return time.Unix(earliest, 0).UTC()
}
func (idx *Index) restoreScannerNextRuns() {
if !idx.useAdaptiveScheduling() {
return
}
idx.scheduleSlotsMu.Lock()
idx.scheduleSlots = make(map[int64][]*Scanner)
idx.scheduleSlotsMu.Unlock()
idx.mu.RLock()
n := len(idx.scanners)
idx.mu.RUnlock()
now := time.Now()
for _, s := range idx.scanners {
var next time.Time
s.withStatsLock(func() {
if s.lastScanned.IsZero() {
s.nextRun = time.Time{}
return
}
tier := utils.Clamp(s.currentSchedule, 0, len(scanScheduleTiers)-1)
d := scanScheduleDuration(tier)
next = computeNextSlotTime(s.lastScanned, now, d)
s.nextRun = next
})
if !next.IsZero() {
idx.registerScannerNextRun(s, next)
}
}
logger.Debugf("[%s] scheduler: restored nextRun for %d scanners (adaptive)", idx.Name, n)
}
func sortedScannerPaths(idx *Index) []string {
idx.mu.RLock()
defer idx.mu.RUnlock()
paths := make([]string, 0, len(idx.scanners))
for p := range idx.scanners {
paths = append(paths, p)
}
sort.Strings(paths)
return paths
}
func (idx *Index) pruneGoneChildScanners() {
idx.mu.RLock()
var gone []string
for path, s := range idx.scanners {
if path == "/" {
continue
}
if !s.directoryExists() {
gone = append(gone, path)
}
}
idx.mu.RUnlock()
for _, path := range gone {
idx.mu.RLock()
s, ok := idx.scanners[path]
idx.mu.RUnlock()
if !ok {
continue
}
idx.scheduleSlotsMu.Lock()
idx.removeScannerFromSlotLocked(s)
idx.scheduleSlotsMu.Unlock()
idx.mu.Lock()
delete(idx.scanners, path)
idx.mu.Unlock()
logger.Debugf("Scheduler removed scanner [%s]: path gone", path)
}
}
func (idx *Index) needsAdaptiveBootstrap() bool {
idx.mu.RLock()
defer idx.mu.RUnlock()
if idx.schedulerStartupPassPending {
return true
}
for _, s := range idx.scanners {
if s.lastScanned.IsZero() {
return true
}
}
return false
}
func (idx *Index) runSerialScanPass() {
idx.pruneGoneChildScanners()
paths := sortedScannerPaths(idx)
idx.mu.Lock()
idx.schedulerBatch++
batchDepth := idx.schedulerBatch
idx.mu.Unlock()
logger.Debugf("[%s] scheduler: serialPass start paths=%d batchDepth=%d adaptive=%v",
idx.Name, len(paths), batchDepth, idx.useAdaptiveScheduling())
defer func() {
idx.mu.Lock()
idx.schedulerBatch--
idx.mu.Unlock()
if err := idx.PostScan(); err != nil {
logger.Errorf("PostScan after serial pass: %v", err)
}
logger.Debugf("[%s] scheduler: serialPass done", idx.Name)
}()
for _, p := range paths {
idx.mu.RLock()
s, ok := idx.scanners[p]
idx.mu.RUnlock()
if !ok {
continue
}
if s.scanPath != "/" && !s.directoryExists() {
s.removeSelf()
continue
}
s.executeScan()
}
}
func (idx *Index) schedulerAdaptiveTick() {
idx.pruneGoneChildScanners()
if idx.needsAdaptiveBootstrap() {
idx.mu.Lock()
startupPass := idx.schedulerStartupPassPending
idx.mu.Unlock()
if startupPass {
logger.Debugf("[%s] scheduler: adaptive startup (serial pass all scanners)", idx.Name)
} else {
logger.Debugf("[%s] scheduler: adaptive tick bootstrap (serial pass all scanners)", idx.Name)
}
idx.runSerialScanPass()
if startupPass {
idx.mu.Lock()
idx.schedulerStartupPassPending = false
idx.mu.Unlock()
}
return
}
drainedAny := false
for {
batch := idx.takeDueScannerBatch(time.Now())
if len(batch) == 0 {
break
}
drainedAny = true
sort.Slice(batch, func(i, j int) bool {
return batch[i].scanPath < batch[j].scanPath
})
firstPath := batch[0].scanPath
logger.Debugf("[%s] scheduler: adaptive slot scanners=%d first=%s", idx.Name, len(batch), firstPath)
for _, s := range batch {
var nextRunStr string
s.withStatsRLock(func() {
if s.nextRun.IsZero() {
nextRunStr = "zero(immediate)"
} else {
nextRunStr = s.nextRun.UTC().Format(time.RFC3339)
}
})
logger.Debugf("[%s] scheduler: adaptive run path=%s nextRun=%s", idx.Name, s.scanPath, nextRunStr)
if s.scanPath != "/" && !s.directoryExists() {
s.removeSelf()
continue
}
s.executeScan()
}
}
if !drainedAny {
idx.mu.RLock()
n := len(idx.scanners)
idx.mu.RUnlock()
now := time.Now()
logger.Debugf("[%s] scheduler: adaptive tick no due slot (now=%v scanners=%d)", idx.Name, now.UTC().Format(time.RFC3339), n)
}
}
func (idx *Index) runIndexScheduler() {
defer func() {
if r := recover(); r != nil {
logger.Errorf("[%s] index scheduler panic recovered: %v", idx.Name, r)
}
}()
if !idx.useAdaptiveScheduling() {
idx.mu.RLock()
n := len(idx.scanners)
idx.mu.RUnlock()
interval := time.Duration(idx.Config.IndexingInterval) * time.Minute
logger.Debugf("[%s] scheduler: started fixed interval=%v scanners=%d", idx.Name, interval, n)
idx.runSerialScanPass()
for {
select {
case <-idx.schedulerStop:
logger.Debugf("[%s] scheduler: fixed loop stopped", idx.Name)
return
case <-time.After(interval):
logger.Debugf("[%s] scheduler: fixed interval wake", idx.Name)
idx.runSerialScanPass()
}
}
}
idx.mu.RLock()
n := len(idx.scanners)
idx.mu.RUnlock()
logger.Debugf("[%s] scheduler: started adaptive scanners=%d", idx.Name, n)
idx.schedulerAdaptiveTick()
for {
now := time.Now()
next := idx.earliestSlotWake(now)
d := time.Until(next)
if d < time.Second {
d = time.Second
}
logger.Debugf("[%s] scheduler: adaptive sleep until=%v (in %v)", idx.Name, next.UTC().Format(time.RFC3339), d)
select {
case <-idx.schedulerStop:
logger.Debugf("[%s] scheduler: adaptive loop stopped", idx.Name)
return
case <-time.After(d):
idx.schedulerAdaptiveTick()
}
}
}
func (idx *Index) stopScheduler() {
idx.schedulerStopOnce.Do(func() {
if idx.schedulerStop != nil {
logger.Debugf("[%s] scheduler: stop channel closed", idx.Name)
close(idx.schedulerStop)
}
})
}
@@ -0,0 +1,69 @@
package indexing
import (
"testing"
"time"
)
func TestAlignCeilToFiveMinutes(t *testing.T) {
loc := time.UTC
base := time.Date(2026, 4, 14, 12, 35, 0, 0, loc)
step := 5 * time.Minute
if got := alignCeilToInterval(base, step); !got.Equal(base) {
t.Fatalf("on boundary: got %v want %v", got, base)
}
withSec := time.Date(2026, 4, 14, 12, 35, 1, 0, loc)
want := time.Date(2026, 4, 14, 12, 40, 0, 0, loc)
if got := alignCeilToInterval(withSec, step); !got.Equal(want) {
t.Fatalf("ceil: got %v want %v", got, want)
}
}
func TestComputeNextSlotTimeTenMinuteGrid(t *testing.T) {
loc := time.UTC
last := time.Date(2026, 4, 14, 12, 0, 0, 0, loc)
ref := time.Date(2026, 4, 14, 12, 0, 30, 0, loc)
interval := 10 * time.Minute
got := computeNextSlotTime(last, ref, interval)
want := time.Date(2026, 4, 14, 12, 10, 0, 0, loc)
if !got.Equal(want) {
t.Fatalf("got %v want %v", got, want)
}
}
func TestScheduleTierBoundsForComplexity(t *testing.T) {
n := len(scanScheduleTiers) - 1
tests := []struct {
c uint
min, max int
}{
{0, 0, n},
{1, 0, 4},
{3, 0, n},
{4, 1, n},
{6, 2, n},
{8, 3, n},
{10, 4, n},
}
for _, tt := range tests {
min, max := scheduleTierBoundsForComplexity(tt.c)
if min != tt.min || max != tt.max {
t.Fatalf("complexity %d: got min=%d max=%d want min=%d max=%d", tt.c, min, max, tt.min, tt.max)
}
}
}
func TestComputeNextSlotTimeHourUsesHourGrid(t *testing.T) {
loc := time.UTC
last := time.Date(2026, 4, 14, 12, 0, 0, 0, loc)
ref := time.Date(2026, 4, 14, 12, 3, 0, 0, loc)
interval := 1 * time.Hour
got := computeNextSlotTime(last, ref, interval)
step := int64(interval / time.Second)
if got.Unix()%step != 0 {
t.Fatalf("expected multiple of %v, got %v", interval, got)
}
if got.Before(ref) {
t.Fatalf("got %v before ref %v", got, ref)
}
}
+8 -10
View File
@@ -67,15 +67,13 @@ func TestMultiScannerMutex(t *testing.T) {
// Create a mock index with multi-scanner support
idx := &Index{
Source: settings.Source{
Name: "test-multiscanner",
Path: "/tmp/test",
Config: settings.SourceConfig{
DisableIndexing: false,
},
Name: "test-multiscanner",
Path: "/tmp/test",
Config: settings.SourceConfig{},
},
mock: true,
db: indexDB,
scanners: make(map[string]*Scanner),
mock: true,
db: indexDB,
scanners: make(map[string]*Scanner),
}
// Create multiple scanners
@@ -113,8 +111,8 @@ func TestMultiScannerMutex(t *testing.T) {
}
// Verify scanner counters can be updated
// Test with active scanner - mark scanner2 as scanning
scanner2.isScanning = true
// Test with active scanner
idx.activeScannerPath = "/test1/"
idx.scanners = map[string]*Scanner{"/test1/": scanner2}
idx.incrementScannerDirs()
idx.incrementScannerFiles()
+18 -8
View File
@@ -30,18 +30,28 @@ type FileInfo struct {
Files []ExtendedItemInfo `json:"files,omitempty"` // files in the directory with optional metadata
Folders []ItemInfo `json:"folders,omitempty"` // folders in the directory
Path string `json:"path,omitempty"` // path scoped to the associated index
// IsDir is the index DB is_dir column; Type alone can disagree (legacy or malformed rows).
IsDir bool `json:"-"`
}
// Lyric is a single line of lyrics with timestamps (if available)
type Lyric struct {
Text string `json:"text"`
Timestamp int64 `json:"timestamp"` // milliseconds
}
// MediaMetadata contains metadata extracted from audio and video files
type MediaMetadata struct {
Title string `json:"title,omitempty"` // track/video title
Artist string `json:"artist,omitempty"` // track artist
Album string `json:"album,omitempty"` // album name
Year int `json:"year,omitempty"` // release year
Genre string `json:"genre,omitempty"` // music/video genre
Track int `json:"track,omitempty"` // track number
Duration int `json:"duration,omitempty"` // duration in seconds
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
Title string `json:"title,omitempty"` // track/video title
Artist string `json:"artist,omitempty"` // track artist
Album string `json:"album,omitempty"` // album name
Year int `json:"year,omitempty"` // release year
Genre string `json:"genre,omitempty"` // music/video genre
Track int `json:"track,omitempty"` // track number
Duration int `json:"duration,omitempty"` // duration in seconds
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
Lyrics []Lyric `json:"lyrics,omitempty"` // lyrics (from embedded tags or .lrc files)
HasLyrics bool `json:"hasLyrics,omitempty"` // checks if lyrics are available without parse them
}
// for efficiency, a response will be a pointer to the data
+20 -14
View File
@@ -317,28 +317,33 @@ func DetectTypeByHeader(realPath string) string {
// returns file type if the file name contains the search term
// returns size of file/dir if the file name contains the search term
func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions) bool {
conditions := options.Conditions
lowerFileName := strings.ToLower(fi.Name)
st := searchTerm
if !conditions["exact"] {
st = strings.ToLower(st)
}
if !strings.Contains(lowerFileName, st) {
return false
}
return fi.MatchesSearchAuxiliaryFilters(options)
}
// MatchesSearchAuxiliaryFilters applies type/size/date constraints from SearchOptions without checking the file name.
// Used when SQLite has already filtered names (e.g. name GLOB patterns).
func (fi ItemInfo) MatchesSearchAuxiliaryFilters(options SearchOptions) bool {
fileTypes := map[string]bool{}
largerThan := int64(options.LargerThan) * 1024 * 1024
smallerThan := int64(options.SmallerThan) * 1024 * 1024
conditions := options.Conditions
lowerFileName := strings.ToLower(fi.Name)
// Convert to lowercase if not exact match
if !conditions["exact"] {
searchTerm = strings.ToLower(searchTerm)
}
// Check if the file name contains the search term
if !strings.Contains(lowerFileName, searchTerm) {
return false
}
// Initialize file size and fileTypes map
var fileSize int64
extension := filepath.Ext(lowerFileName)
// Collect file types using the actual detected MIME type, not just extension
for _, k := range AllFiletypeOptions {
if IsMatchingDetectedType(fi.Type, extension, k) {
fileTypes[k] = true
@@ -348,7 +353,6 @@ func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions)
fileTypes["dir"] = isDir
fileSize = fi.Size
// Evaluate all conditions
for t, v := range conditions {
if t == "exact" {
continue
@@ -367,7 +371,6 @@ func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions)
}
}
default:
// Handle other file type conditions
notMatchType := v != fileTypes[t]
if notMatchType {
return false
@@ -405,6 +408,9 @@ func IsDirectory(fileInfo os.FileInfo) bool {
// Text files, office documents, and PDFs should NOT bubble up to folder previews.
// This ensures consistency between indexing and preview generation.
func ShouldBubbleUpToFolderPreview(item ItemInfo) bool {
if item.Type == "directory" || !item.HasPreview {
return false
}
// Get the simple type (e.g., "image", "video", "text")
simpleType := strings.Split(item.Type, "/")[0]
// Text files should NOT bubble up
@@ -0,0 +1,69 @@
package iteminfo
import "testing"
func TestShouldBubbleUpToFolderPreview(t *testing.T) {
tests := []struct {
name string
item ItemInfo
want bool
}{
{
name: "directory type never bubbles",
item: ItemInfo{Name: "sub", Type: "directory"},
want: false,
},
{
name: "text markdown does not bubble",
item: ItemInfo{Name: "README.md", Type: "text/markdown"},
want: false,
},
{
name: "plain text does not bubble",
item: ItemInfo{Name: "LICENSE", Type: "text/plain; charset=utf-8"},
want: false,
},
{
name: "image does not bubble without HasPreview",
item: ItemInfo{Name: "a.png", Type: "image/png", HasPreview: false},
want: false,
},
{
name: "image bubbles when HasPreview",
item: ItemInfo{Name: "a.png", Type: "image/png", HasPreview: true},
want: true,
},
{
name: "video bubbles when HasPreview",
item: ItemInfo{Name: "clip.mp4", Type: "video/mp4", HasPreview: true},
want: true,
},
{
name: "audio bubbles when HasPreview (e.g. album art)",
item: ItemInfo{Name: "track.mp3", Type: "audio/mpeg", HasPreview: true},
want: true,
},
{
name: "OnlyOffice-backed extension does not bubble",
item: ItemInfo{Name: "report.docx", Type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
want: false,
},
{
name: "unknown blob type does not bubble",
item: ItemInfo{Name: "data.bin", Type: "application/octet-stream"},
want: false,
},
{
name: "empty type does not bubble",
item: ItemInfo{Name: "weird", Type: ""},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ShouldBubbleUpToFolderPreview(tt.item); got != tt.want {
t.Fatalf("ShouldBubbleUpToFolderPreview() = %v, want %v", got, tt.want)
}
})
}
}
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff. Show more