Compare commits

..
369 changed files with 12338 additions and 26392 deletions

No files matched your search

-1
View File
@@ -23,7 +23,6 @@ frontend/tsconfig.tsbuildinfo
/frontend/generated
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/frontend/sharePrepStorage.json
/backend/vendor
/backend/*.cov
/backend/*test_config.yaml
-174
View File
@@ -2,180 +2,6 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.5.2
**Notes**:
- [docker] upgraded ffmpeg version 8.1.1 to 8.1.2
**BugFixes**:
- Fixed OnlyOffice reopening an older editor state after a document is modified (#2633) (#2578).
- Cannot "Reset and generate new two-factor code" to reset the TOTP for a user (#2399) (#2641).
## v1.5.1
**BugFixes**:
- fix http config section being dropped so trustedHeaders and disableRateLimit apply (#2602) (#2560)
- fix upload shares with password (#2589) (#2573)
- fix token when returning from preview on shares with pass (#2588) (#2465)
- fix share undefined url after editing (#2567) (#2523)
## v1.5.0
**New Features**:
- Added basic html viewer with relative reference support (#2522)
- Shares can have pinned files, requires a user to have edit access to share (#2522)
- Enhanced search
- now uses "lazy" match by default. (#2509)
- added missing `case sensitive` option in the UI
- Progressive Web App (PWA) improvements
- restored install prompt with sidebar install button (#2086)
- camera and video capture buttons on upload (mobile-friendly `capture` inputs)
- send files to other apps via the Web Share API (`Send to app` in the context menu)
- App notifications for file operations (#2478)
- optional browser notifications when uploads, chunked downloads, move/copy, or failures finish while the tab is in the background
- single on/off toggle in Notifications settings, stored in browser local storage
**Notes**:
- [docker] upgraded ffmpeg version 8.1 to 8.1.1
**BugFixes**:
- fixed PWA manifest `scope` and `id` so install works when the app is served under a base URL
- installing a public share as a PWA now opens the share URL instead of the site root (#2302)
## v1.4.4
**BugFixes**:
- Pinning/Unpinning file resets users' scope permission (#2532)
## v1.4.3
**Security**:
- [High] Path traversal in subtitle handler allows any authenticated user to read arbitrary files (GHSA-vvp7-h4fj-m28w)
**New Features**:
- pinned files/folders (#2396) (#2510)
- Markdown Image relative reference support (#2355)
- Add an option to create a new folder for unarchiving (#2338)
**Notes**:
- added fallback to show text in notification if copy fails (#2517)
- added `alt`+`arrow up` shortcut as alias of `backspace` to go into parent directory (#2501) (#2521)
- added `alt`+`arrow down` shortcut as alias of `enter` to open files in Listing View (#2501) (#2521)
- updated help menu with better translations
- migrate i18n to v11 (#2472) (#2504)
- migrate eslint (#2459)
- improved preview cancellation to improve performance when navigating UI.
**BugFixes**:
- fix lyrics regex parsing (#2505)
- fix folder previews issue (#2487) (#2492)
- fix accidental exit on images while using gestures (#2508)
- navigating using the up/down arrow keys in Listing View was "jumping randomly" in some view modes (#2521)
- socket field in config.yaml is ignored (#2497)
- fix keep opened file selected after closing its preview @anpryl (#2515)
## v1.4.2
**Security**:
- [Moderate] Add Rate Limiting on Authentication Endpoint Enables Brute Force Attacks (GHSA-r4v7-6wcg-ghj5)
- [Critical] Path traversal in public share PATCH allows file ops outside shared directory -- thanks @Revanth011 and @fg0x0 (GHSA-qqqm-5547-774x)
**New Features**:
- read-only source configuration via `source.config.readOnly: true` (#2438)
**Notes**:
- auth rate limiting can be disabled via `auth.disableRateLimit`
- updated share hash middleware (#2443)
- updated source info popup to include private and readOnly properties
**BugFixes**:
- Logout from share page now redirects to the share instead of `/Login` again. (#2245)
- `This location cannot be reached` error when navigating with FileTree in shares. (#2245)
- Fix FileTree rename and move actions in previews. (#2245)
- Delete prompt not showing date and thumbnails in some previews. (#2245)
- fix path slash issue on windows (#2451) (#2433) (#2419)
- Always force url rewrite for onlyoffice internal URL. Fixes Error saving with OnlyOffice (#2450)
- Overriding a Deny with an Allow not working (#2405)
## v1.4.1
**Security**:
- Fix critical: unauthenticated user can view source info
## v1.4.0
**New Features**:
- Add Passkey (WebAuthn) support for passwordless authentication (#2287) (#2379) -- thanks @juansoler
- Lyrics support for audio files (#2360):
- Updated UI for audio previews!
- The lyrics and the player gets syncronized in UI with the audio player.
- Supports embedded lyrics (ID3v2 USLT, flac, ogg, clyr) and sidecar `.lrc` files with the same name as the audio file.
- The `Q` shortcut in audio files will toggle the new panel visibility.
- `E` shortcut to change between tabs of the panel if it's open.
- Api log filtering support via `apiFilter` option for logging.
- regex filter that excludes matching full api paths from being logged. (eg. `/user\?id\=self`) Defaults to `^/health|^/favicon.ico|^/static|^/public/static`
- Add config option to disable /health endpoint logging (#2291)
- More shortcuts (#2300)
- `CTRL+F1` switch to normal view.
- `CTRL+F2` switch to gallery view.
- `CTRL+F3` switch to list view.
- `Double click` on empty space to select all items.
- Option to hide certain files in UI by extension (#2403) (#2277)
- Additional search features
- wildcard search similar to regex, but limited to sqlite GLOB syntax (#769)
- advanced search tool that shows all results in a listing thats sortable and actionable like a normal listing (#1051)
- advanced search supports multiple custom scopes per search
- multiple search terms with AND or OR logic support
**Notes**:
- Enhanced indexing scheduler which doesn't wake the disk as often.
- New API route `media/lyrics` used to fetch and parse lyrics (embedded or from `.lrc` sidecar) (#2360)
- Swiping down gesture in fullscreen videos exit fullscreen instead of close preview.
- Improved styles for path selection and tables
- Improved style of drag and drop into listing view (#2407)
- Edit Sidebar links has new "show tools in sidebar" toggle and all users have this enabled by default. can be disabled via for new users `userDefaults.sidebar.showTools: false`
- Update user defaults ordering (#1140)
- Save view modes and sizes into local storage instead of db (#2301)
**BugFixes**:
- Blue overlay when using gestures in video files on mobile (#2360)
- Playback queue wasn't updating when changing of folder (#2360)
- Added missing `UserDefaults.listing.showCopyPath` option to config file (#2364)
- Delete prompt thumbnail didn't worked when using it with the `delete` shortcut. (#2407)
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
- Drag and drop style state was stuck until page reload (#2407)
- Navigate close settings shows "something went wrong" (#2047)
## v1.3.10
**Notes**:
- When indexing is disabled for a source, the usage will always reported as partition size.
- removed deprecated `source.config.disableIndexing`, see [rules](https://filebrowserquantum.com/en/docs/advanced/source-configuration/conditional-rules/#disable-indexing)
**BugFixes**:
- Disable index option not working (#2385)
## v1.3.9
**Security**:
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
**BugFixes**:
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
## v1.3.8
**BugFixes**:
- Quick download icon style after icon change.
- Missing error popup for resource creatoin actions (upload/create)
- EnforcedOtp login failure until restart (#2330)
- Thumbnails for Folders only display sporadically (#2353)
- Unwanted user scope change for users with non-default scopes (#2347)
- Fix sidebar source info totals (#2321) (#2322) (#982)
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
## v1.3.7
**Notes**:
+64 -7
View File
@@ -5,22 +5,17 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
## Prerequisites
- **Go 1.25+** (see `backend/go.mod`)
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Node.js 20.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Docker** (optional, for containerized development)
- **Git** especially improtant on windows -- needed for bash support.
### Optional Tools
- **ffmpeg/ffprobe**: For media features (subtitles, thumbnails, heic)
- **ffmpeg**: For media features (subtitles, thumbnails, heic)
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
- **exiftool**: For photo metadata processing (RAW/HEIC previews, EXIF orientation)
- Ubuntu/Debian: `sudo apt-get install libimage-exiftool-perl`
- macOS: `brew install exiftool`
- Windows: Download from [exiftool.org](https://exiftool.org/) (install `exiftool.exe` and add it to your PATH, or set `integrations.media.exiftoolPath` in config)
- **mupdf-tools**: For PDF preview generation
- Ubuntu/Debian: `sudo apt-get install mupdf-tools`
- macOS: `brew install mupdf-tools`
@@ -95,6 +90,18 @@ make test-playwright # E2E tests in Docker
E2E tests run with three authentication modes: standard auth, no auth, and proxy auth.
### Coverage & Performance
```bash
cd backend
./run_check_coverage.sh # Coverage report with HTML output
./run_benchmark.sh # Benchmarks
```
**Code Coverage:**
- View report: Open `backend/coverage.html` after running coverage script
- CI enforces coverage for critical packages
- Use `go test -cover` for quick package coverage
## Code Standards
### Backend (Go)
@@ -149,3 +156,53 @@ type(scope): description
Types: feat, fix, docs, refactor, test, chore
```
## Roadmap
Check the [Project Roadmap](https://github.com/users/gtsteffaniak/projects/4/views/2) to see issues sorted by priority. This helps you understand what features are planned and where you can contribute most effectively.
## Troubleshooting
### Common Issues
**Build failures:**
```bash
# Frontend
cd frontend && rm -rf node_modules && npm install
# Backend
cd backend && go mod tidy && go clean -modcache
```
**Authentication Issues:**
Always first enable debug logging in your config file if you have issues.
1. **OIDC Login Fails:**
- Check redirect URLs match your config
- Verify OIDC provider settings
- Enable debug logs to see auth flow
- Common issue: mismatched callback URLs
2. **Proxy Auth Not Working:**
- Verify header names match config
- Check nginx/reverse proxy passes headers
- Test with: `curl -H "X-Auth: username" localhost:8080`
- Enable `--log-level debug` to see headers
3. **TOTP Issues:**
- Ensure server time is synchronized
- Check QR code generation in logs
- Test with authenticator app time settings
- Database might have stale TOTP secrets
4. **Session/Cookie Problems:**
- Clear browser cookies and localStorage
- Verify cookie domain settings
- Try incognito/private browsing mode
### Getting Help
- **Wiki**: [Project Wiki](https://github.com/gtsteffaniak/filebrowser/wiki)
- **Issues**: [GitHub Issues](https://github.com/gtsteffaniak/filebrowser/issues)
- **PR Template**: See `.github/PULL_REQUEST_TEMPLATE.md`
+3 -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/0b548794f2ac4871a0cf7aa9ecab049f)](https://app.codacy.com/gh/gtsteffaniak/filebrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![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)
@@ -17,6 +17,8 @@
## Pinned
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
## About
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:8.1.2-decode AS ffmpeg
FROM gtstef/ffmpeg:8.1-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
-2
View File
@@ -19,7 +19,5 @@ COPY [ "./backend/http/embed/", "./http/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
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,4 +15,3 @@ userDefaults:
permissions:
download: true
create: true
share: true
@@ -2,8 +2,7 @@ server-main: &server-main
port: 80
baseURL: "/"
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
@@ -12,7 +11,5 @@ server-main: &server-main
- path: "."
name: "docker"
config:
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
disableIndexing: true
defaultEnabled: true
+1 -2
View File
@@ -1,8 +1,7 @@
server:
port: 8080
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
config:
+1 -2
View File
@@ -2,8 +2,7 @@ server:
port: 80
baseURL: "/files/"
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "exclude"
-1
View File
@@ -1,7 +1,6 @@
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
+1 -2
View File
@@ -2,8 +2,7 @@ server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
+1 -2
View File
@@ -2,8 +2,7 @@ server:
port: 8080
baseURL: "/subpath"
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
config:
+1 -2
View File
@@ -2,8 +2,7 @@ server:
port: 8080
baseURL: "/files/"
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
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: 1,
retries: 2,
/* Opt out of parallel tests on CI. */
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
@@ -53,6 +53,8 @@ export default defineConfig({
...devices["Desktop Chrome"],
theme: 'dark',
},
testMatch: /.*screenshots.spec.ts/,
retries: 0,
},
{
name: "light-screenshots",
@@ -60,6 +62,8 @@ export default defineConfig({
...devices["Desktop Chrome"],
theme: 'light',
},
testMatch: /.*screenshots.spec.ts/,
retries: 0,
},
],
});
+2 -5
View File
@@ -2,8 +2,7 @@ server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
@@ -12,10 +11,8 @@ server:
- path: "."
name: "docker"
config:
disableIndexing: true
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
- path: "/tests/playwright-files"
name: "access"
config:
+1 -2
View File
@@ -2,8 +2,7 @@ server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
+5 -5
View File
@@ -357,7 +357,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
{Name: "Test Object", Type: "directory"},
},
}
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
// Delete the old directory item from the database before replacing it with a file
idx.DeleteMetadata("/Test Object", true, false)
@@ -375,7 +375,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
},
},
}
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
// Verify the directory was replaced with a file in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
@@ -467,7 +467,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
},
}
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
// Delete the old file item from the database before replacing it with a directory
idx.DeleteMetadata("/Test Object", false, false)
@@ -483,7 +483,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
Type: "directory",
},
}
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
// Verify the file was replaced with a directory in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
@@ -684,7 +684,7 @@ func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
}
indexing.StopAllScanners()
// Give scanners time to finish their cleanup (defer blocks in executeScan)
// Give scanners time to finish their cleanup (defer blocks in tryAcquireAndScan)
time.Sleep(100 * time.Millisecond)
indexing.ClearTestIndices()
t.Logf("iteration %d: cleaned scanners and indices", iter)
+26 -177
View File
@@ -1,13 +1,11 @@
package files
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -27,8 +25,6 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
var reDuration = regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
var CheckPermissionsFunc = checkPermissionsImpl
@@ -50,7 +46,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, false, sharedFFmpegService); err != nil {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
@@ -89,7 +85,7 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
wg.Wait()
}
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping).
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
// Add OnlyOffice ID if applicable
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
@@ -147,7 +143,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 indexPath, "", errors.ErrAccessDenied
return "", "", errors.ErrAccessDenied
}
return indexPath, userScope, nil
}
@@ -157,37 +153,22 @@ 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, _, topLevelErr := CheckPermissions(opts, access, user)
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
if topLevelErr != nil && !accessRulesErr {
return items, topLevelErr
indexPath, _, err := CheckPermissions(opts, access, user)
if err != nil {
return items, err
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return items, fmt.Errorf("could not get index: %v ", opts.Source)
}
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
HideFileExt: opts.HideFileExt,
Expand: true,
SkipExtendedAttrs: true,
})
@@ -200,15 +181,8 @@ 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)
}
}
@@ -223,17 +197,16 @@ func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.Use
// FileInfoFasterFunc is a variable that can be mocked in tests
var FileInfoFasterFunc = fileInfoFasterImpl
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, shareStore *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
return FileInfoFasterFunc(opts, access, user, shareStore)
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
return FileInfoFasterFunc(opts, access, user, share)
}
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, shareStore *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
indexPath, userScope, topLevelErr := CheckPermissions(opts, access, user)
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
if topLevelErr != nil && !accessRulesErr {
return response, topLevelErr
indexPath, userScope, err := CheckPermissions(opts, access, user)
if err != nil {
return response, err
}
// Get index
idx := indexing.GetIndex(opts.Source)
@@ -246,64 +219,42 @@ 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
}
// 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 shareStore != nil && user.Permissions.Share && opts.ShowSharedAttr {
if share != nil && user.Permissions.Share && opts.ShowSharedAttr {
for i := range response.Files {
file := &response.Files[i]
file.IsShared = shareStore.IsShared(response.Path+file.Name, idx.Path, user.ID)
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path, user.ID)
}
for i := range response.Folders {
folder := &response.Folders[i]
folder.IsShared = shareStore.IsShared(response.Path+folder.Name, idx.Path, user.ID)
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path, user.ID)
}
response.IsShared = shareStore.IsShared(response.Path, idx.Path, user.ID)
response.IsShared = share.IsShared(response.Path, idx.Path, user.ID)
}
if info.Type == "directory" && opts.ShowPinnedItems {
if opts.ShareHash != "" && shareStore != nil {
if link, err := shareStore.GetByHash(opts.ShareHash); err == nil {
if shareRelDir, err := link.ShareRelativeDir(response.Path); err == nil {
response.PinnedItems = link.PinnedItems.NamesForDirectory(shareRelDir)
}
}
} else if source, ok := users.ResolveSourceKey(response.Source); ok {
response.PinnedItems = user.PinnedNamesForDirectory(source.Path, response.Path)
}
}
defer finalizeResponse(response, info, response.RealPath, user, userScope)
if opts.SkipExtendedAttrs {
if info.Type == "directory" && opts.HideFileExt != "" {
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
}
return response, nil
}
// Process directory metadata if requested
if info.Type == "directory" {
processDirectoryMetadata(response, idx, opts)
if opts.HideFileExt != "" {
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
}
}
// Process single file content/metadata
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
@@ -355,7 +306,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, false, nil)
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
@@ -389,57 +340,10 @@ func generateOfficeId(realPath string) string {
return key
}
// Parses raw LRC text into a slice of Lyric.
// Lines with [hh:mm:ss.xx] (timestamps) will be parsed to ms for synced lyrics
// The ones without timestamp are kept as unsynced (timestamp 0).
func parseLRC(raw string) []iteminfo.Lyric {
var lines []iteminfo.Lyric
scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
matches := reDuration.FindStringSubmatch(line)
if len(matches) == 6 {
// Hours default to 0 (if not present in the timestamps)
h := 0
if matches[1] != "" {
h, _ = strconv.Atoi(matches[1])
}
min, _ := strconv.Atoi(matches[2])
sec, _ := strconv.Atoi(matches[3])
msStr := matches[4]
// Normalise to 3 digits, some songs have more than 3 digits in the milliseconds
switch {
case len(msStr) < 3:
msStr += strings.Repeat("0", 3-len(msStr))
case len(msStr) > 3:
msStr = msStr[:3]
}
ms, _ := strconv.Atoi(msStr)
timestamp := int64((h*3600+min*60+sec)*1000 + ms)
text := strings.TrimSpace(matches[5])
lines = append(lines, iteminfo.Lyric{
Text: text,
Timestamp: timestamp,
})
} else {
// Line without a valid timestamp – treat as unsynced (timestamp 0)
lines = append(lines, iteminfo.Lyric{
Text: line,
Timestamp: 0,
})
}
}
return lines
}
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
// and optionally extracts duration using the ffmpeg service with concurrency control
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
file, err := os.Open(realPath)
if err != nil {
return err
@@ -475,30 +379,6 @@ 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 {
@@ -531,37 +411,6 @@ 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 {
@@ -596,7 +445,7 @@ func DeleteFiles(source, absPath string, isDir bool) error {
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
}
if !index.Config.ResolvedRules.IndexingDisabled {
if !index.Config.DisableIndexing {
indexPath := index.MakeIndexPath(absPath, isDir)
// Perform the physical deletion
@@ -633,7 +482,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.ResolvedRules.IndexingDisabled {
if idx.Config.DisableIndexing {
return nil
}
// Always normalize path using MakeIndexPath
@@ -741,7 +590,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.ResolvedRules.IndexingDisabled {
if !srcIdx.Config.DisableIndexing {
go func() {
if isSrcDir {
srcIdx.DeleteMetadata(srcIndexPath, true, true)
@@ -756,7 +605,7 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
// Handle DESTINATION indexing
// Run async to avoid blocking the HTTP response
if !dstIdx.Config.ResolvedRules.IndexingDisabled {
if !dstIdx.Config.DisableIndexing {
if isSrcDir {
go func() {
// Recursively index the moved directory tree
@@ -814,7 +663,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.ResolvedRules.IndexingDisabled {
if srcIdx != nil && !srcIdx.Config.DisableIndexing {
srcRefreshPath := realsrc
if !isSrcDir {
srcRefreshPath = filepath.Dir(realsrc)
@@ -825,7 +674,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.ResolvedRules.IndexingDisabled {
if dstIdx != nil && !dstIdx.Config.DisableIndexing {
if isSrcDir {
go func() {
// Recursively index the copied directory tree
-1
View File
@@ -446,7 +446,6 @@ 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",
+1 -5
View File
@@ -25,11 +25,7 @@ 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 {
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]
source, ok := settings.Config.Server.SourceMap[scope.Name]
if !ok {
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
}
-19
View File
@@ -240,22 +240,3 @@ 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
}
+4 -3
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
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
if useCache {
if found {
splitSecret := strings.Split(cachedSecret, "||")
if len(splitSecret) < 2 {
return fmt.Errorf("invalid cached OTP token format")
@@ -173,7 +173,9 @@ 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,
@@ -196,7 +198,6 @@ 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
@@ -1,296 +0,0 @@
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
}
+77 -12
View File
@@ -110,13 +110,14 @@ func runCLI() (bool, bool) {
}
}
dbExists := getStore(storeCfg)
switch cliSubcmd {
case "setup":
createConfig(configPath)
return false, false
return false, dbExists
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 {
@@ -127,7 +128,6 @@ func runCLI() (bool, bool) {
}
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)
@@ -143,9 +143,8 @@ func runCLI() (bool, bool) {
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", setKind)
os.Exit(1)
}
return false, false
return false, dbExists
default:
dbExists := getStore(storeCfg)
return true, dbExists
}
}
@@ -226,7 +225,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.Path,
Name: source.Name,
Scope: source.Config.DefaultUserScope,
})
}
@@ -238,7 +237,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.Account.Permissions)
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser, newUser.Permissions)
if err != nil {
@@ -259,7 +258,7 @@ func setUser(dbConfig string, asAdmin bool) error {
}
// Ensure version is set for existing users being updated
if user.Version == 0 {
user.Version = users.CurrentUserMigrationVersion
user.Version = 1
}
err = store.Users.Save(user, true, false)
if err != nil {
@@ -269,6 +268,51 @@ 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 {
@@ -300,9 +344,30 @@ 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.SetDefaults(false)
config.Server.Sources = []*settings.Source{{Path: "./"}}
config := Settings{
Server: Server{
Logging: []LogConfig{
{
Levels: "info|warning|error",
ApiLevels: "info|warning|error",
Output: "stdout",
NoColors: false,
Utc: false,
},
},
Sources: []Source{
{
Path: "",
},
},
},
}
fmt.Println("--- Starting Configuration Setup ---")
realPath := ""
@@ -369,8 +434,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.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")
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")
fmt.Println("--- Configuration Complete ---")
-9
View File
@@ -12,7 +12,6 @@ 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"
@@ -177,9 +176,6 @@ 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.")
}
@@ -220,11 +216,6 @@ 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
}
+30 -46
View File
@@ -44,12 +44,10 @@ func validateUserInfo(newDB bool) {
if updateTokens(user) {
updateUser = true
}
if updateShowToolsInSidebar(user) {
updateUser = true
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
if user.Username == adminUser && adminPass != "" && user.LoginMethod == users.LoginMethodPassword {
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
if user.Username == adminUser && adminPass != "" && passwordEnabled {
logger.Info("Resetting admin user to default username and password.")
user.Permissions.Admin = true
user.Password = settings.Config.Auth.AdminPassword
@@ -65,11 +63,7 @@ func validateUserInfo(newDB bool) {
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
}
}
fields := []string{"Scopes", "SidebarLinks", "Tokens", "Permissions", "Preview", "ShowFirstLogin", "LoginMethod", "Version", "ShowToolsInSidebar"}
if changePass {
fields = append(fields, "Password")
}
err := store.Users.Update(user, true, fields...)
err := store.Users.Save(user, changePass, true)
if err != nil {
logger.Errorf("could not update user: %v", err)
}
@@ -81,12 +75,10 @@ func updateUserScopes(user *users.User) bool {
newScopes := []users.SourceScope{}
seen := make(map[string]struct{})
// Build map of existing scopes keyed by canonical source path (DB may use path or display name).
// Build map for existing scopes by Name
existing := make(map[string]users.SourceScope)
for _, s := range user.Scopes {
if info, ok := users.ResolveSourceKey(s.Name); ok {
existing[info.Path] = s
}
existing[s.Name] = s
}
// Preserve order by using Config.Server.Sources
@@ -102,6 +94,7 @@ func updateUserScopes(user *users.User) bool {
} else {
continue
}
newScopes = append(newScopes, users.SourceScope{
Name: src.Path,
Scope: existingScope.Scope,
@@ -109,30 +102,17 @@ func updateUserScopes(user *users.User) bool {
seen[src.Path] = struct{}{}
}
// Preserve explicit or legacy scopes not already merged (e.g. non-defaultEnabled sources, removed sources).
// Preserve user-defined scopes not matching current sources, append to end
for _, s := range user.Scopes {
if info, ok := users.ResolveSourceKey(s.Name); ok {
if _, already := seen[info.Path]; already {
continue
}
if _, ok := seen[s.Name]; !ok {
newScopes = append(newScopes, s)
}
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
@@ -147,6 +127,7 @@ 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
@@ -188,7 +169,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = users.CurrentUserMigrationVersion
user.Version = 2
if updateUser {
createBackup = true
}
@@ -222,11 +203,11 @@ func updateSidebarLinks(user *users.User) bool {
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
sourceLinksCount++
if link.SourceName == "" {
continue
}
if _, ok := users.ResolveSourceKey(link.SourceName); ok {
validSourceLinksCount++
// Check if this source still exists
if link.SourceName != "" {
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
validSourceLinksCount++
}
}
}
}
@@ -238,6 +219,8 @@ 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 {
@@ -246,24 +229,25 @@ func updateSidebarLinks(user *users.User) bool {
}
}
// Add new source links based on user's scopes
for _, scope := range user.Scopes {
info, ok := users.ResolveSourceKey(scope.Name)
if !ok {
continue
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,
})
}
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
}
@@ -278,6 +262,6 @@ func updateTokens(user *users.User) bool {
user.Tokens[name] = token
}
}
user.Version = users.CurrentUserMigrationVersion
user.Version = 2
return true
}
-1
View File
@@ -25,7 +25,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
"sourceA": &sourceA,
"sourceB": &sourceB,
}
settings.InitializeUserResolvers()
// ---------------------
// Test Cases Definition
-6
View File
@@ -26,10 +26,4 @@ var (
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")
)
@@ -19,78 +19,63 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{
Sidebar: UserDefaultsSidebar{
DisableQuickToggles: true,
HideFileActions: true,
DisableHideOnPreview: true,
Sticky: false,
HideFiles: true,
ShowTools: boolPtr(true),
EditorQuickSave: true,
HideSidebarFileActions: true,
DisableQuickToggles: true,
DisableSearchOptions: true,
StickySidebar: false,
HideFilesInTree: true,
DarkMode: &dark,
Locale: "de",
ViewMode: "list",
SingleClick: true,
ShowHidden: true,
DateFormat: true,
GallerySize: 7,
ThemeColor: "var(--red)",
QuickDownload: true,
DisablePreviewExt: ".exe",
DisableViewingExt: ".bat",
DisableSettings: true,
LockPassword: true,
DisableUpdateNotifications: true,
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
DisableOfficePreviewExt: "legacy",
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
DebugOffice: true,
PreferEditorForMarkdown: true,
LoginMethod: "password",
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
DownloadChunkSize: 2,
},
Listing: UserDefaultsListing{
DeleteWithoutConfirming: true,
DateFormat: true,
ShowHidden: true,
QuickDownload: true,
ShowSelectMultiple: true,
SingleClick: true,
HideFileExt: "",
ShowCopyPath: false,
DeleteAfterArchive: false,
ViewMode: "list",
GallerySize: 7,
Permissions: UserDefaultsPermissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: &dlFalse,
},
Preview: UserDefaultsPreview{
DisableHideSidebar: true,
Image: boolPtr(false),
Video: boolPtr(false),
Audio: boolPtr(false),
MotionVideoPreview: boolPtr(false),
Office: boolPtr(false),
PopUp: boolPtr(false),
DisablePreviewExt: ".exe",
HighQuality: boolPtr(true),
AutoplayMedia: boolPtr(false),
DefaultMediaPlayer: 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,
},
},
}
@@ -130,7 +115,7 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
DisablePreviewExt: ".exe",
DisableViewingExt: ".bat",
DisableUpdateNotifications: true,
DisableOfficePreviewExt: ".x", // deprecated: mapped to DisableOnlyOfficeExt
DisableOfficePreviewExt: "legacy",
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
@@ -138,7 +123,6 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
PreferEditorForMarkdown: true,
ShowToolsInSidebar: true,
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
@@ -182,12 +166,8 @@ func TestApplyUserDefaults_setsLoginMethodWhenEmpty(t *testing.T) {
defer func() { Config = saved }()
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{
Account: UserDefaultsAccount{
LoginMethod: "jwt",
},
},
Server: Server{},
UserDefaults: UserDefaults{LoginMethod: "jwt"},
}
u := &users.User{Username: "x"}
ApplyUserDefaults(u)
@@ -201,12 +181,8 @@ func TestApplyUserDefaults_preservesLoginMethodWhenAlreadySet(t *testing.T) {
defer func() { Config = saved }()
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{
Account: UserDefaultsAccount{
LoginMethod: "password",
},
},
Server: Server{},
UserDefaults: UserDefaults{LoginMethod: "password"},
}
u := &users.User{Username: "x", LoginMethod: users.LoginMethodProxy}
ApplyUserDefaults(u)
+1 -11
View File
@@ -61,7 +61,6 @@ 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 {
@@ -112,19 +111,10 @@ type LdapConfig struct {
type JwtAuthConfig struct {
AuthCommon `json:",inline"`
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
Secret string `json:"secret"` // secret: Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
Secret string `json:"secret"` // Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
}
// 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
+43 -403
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
@@ -38,8 +39,6 @@ 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. "
@@ -52,7 +51,6 @@ func Initialize(configFile string) {
}
setupFs()
setupServer()
setupHttp()
setupAuth(false)
setupSources(false)
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
@@ -62,9 +60,6 @@ func Initialize(configFile string) {
}
func setupServer() {
if Config.Server.Socket != "" && (Config.Server.TLSCert != "" || Config.Server.TLSKey != "") {
logger.Fatal("server.socket cannot be used with tlsCert or tlsKey")
}
if Config.Server.ListenAddress == "" {
Config.Server.ListenAddress = "0.0.0.0"
}
@@ -86,15 +81,6 @@ 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)
@@ -112,10 +98,6 @@ func setupFs() {
// 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)
}
@@ -480,17 +462,13 @@ 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.Account.LoginMethod = Config.Auth.AuthMethods[0]
Config.UserDefaults.LoginMethod = Config.Auth.AuthMethods[0]
}
@@ -502,324 +480,31 @@ func setupLogging() {
},
}
}
for i := range Config.Server.Logging {
cfg := &Config.Server.Logging[i]
for _, logConfig := range Config.Server.Logging {
// Enable debug logging automatically in dev mode
levels := cfg.Levels
levels := logConfig.Levels
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
levels = "info|warning|error|debug"
}
pattern := cfg.ApiFilter
if pattern == "" {
pattern = "^/health|^/favicon.ico|^/static|^/public/static"
logConfig := logger.JsonConfig{
Levels: levels,
ApiLevels: logConfig.ApiLevels,
Output: logConfig.Output,
Utc: logConfig.Utc,
NoColors: logConfig.NoColors,
Json: logConfig.Json,
Structured: false,
}
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)
err := logger.EnableCompatibilityMode(logConfig)
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 {
@@ -857,7 +542,6 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
"integrations": true,
"frontend": true,
"userDefaults": true,
"http": true,
}
filteredConfig := make(map[string]interface{})
@@ -973,7 +657,13 @@ 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()
if cpus > 0 && !generate {
numCpus = cpus
}
database := os.Getenv("FILEBROWSER_DATABASE")
if database == "" {
database = "database.db"
@@ -984,7 +674,7 @@ func SetDefaults(generate bool) Settings {
s := Settings{
Server: Server{
Port: 80,
NumImageProcessors: 4,
NumImageProcessors: numCpus,
BaseURL: "",
Database: database,
SourceMap: map[string]*Source{},
@@ -1018,27 +708,25 @@ func SetDefaults(generate bool) Settings {
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
// 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,
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
},
Preview: UserDefaultsPreview{
Image: boolPtr(true),
@@ -1047,50 +735,15 @@ func SetDefaults(generate bool) Settings {
MotionVideoPreview: boolPtr(true),
Office: boolPtr(true),
PopUp: boolPtr(true),
DisablePreviewExt: "",
HighQuality: boolPtr(true),
AutoplayMedia: 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
@@ -1173,19 +826,6 @@ 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() {
const imageName = "login icon"
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
+4 -32
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)
@@ -94,34 +94,6 @@ func TestConfigLoadEnvVars(t *testing.T) {
}
}
func TestConfigLoadHttpSection(t *testing.T) {
testDir := t.TempDir()
configContent := []byte(`
server:
sources:
- path: "."
http:
trustedHeaders:
- X-Forwarded-For
disableRateLimit: true
`)
configFile := filepath.Join(testDir, "config.yaml")
if err := os.WriteFile(configFile, configContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
if err := loadConfigWithDefaults(configFile, true); err != nil {
t.Fatalf("error loading config file: %v", err)
}
if len(Config.Http.TrustedHeadersArray) != 1 || Config.Http.TrustedHeadersArray[0] != "X-Forwarded-For" {
t.Fatalf("expected trusted headers to contain X-Forwarded-For, got %v", Config.Http.TrustedHeadersArray)
}
if !Config.Http.DisableRateLimit {
t.Fatal("expected http.disableRateLimit to be true")
}
}
func TestConfigLoadSpecificValues(t *testing.T) {
// Create isolated test directory
testDir := t.TempDir()
@@ -134,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.Pointer {
if fieldType.Kind() == reflect.Ptr {
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.Pointer {
if v.Kind() == reflect.Ptr {
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.Pointer && elemType.Elem().Kind() == reflect.Struct)
(elemType.Kind() == reflect.Ptr && 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.Pointer {
if v.Kind() == reflect.Ptr {
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.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
nestedTypeName := field.Type.Name()
if field.Type.Kind() == reflect.Pointer {
if field.Type.Kind() == reflect.Ptr {
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.Pointer {
if v.Kind() == reflect.Ptr {
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.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
nestedTypeName := field.Type.Name()
if field.Type.Kind() == reflect.Pointer {
if field.Type.Kind() == reflect.Ptr {
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
@@ -1,221 +0,0 @@
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")
}
}
+48 -82
View File
@@ -21,7 +21,7 @@ func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
}
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
func ConvertPermissionsToUsers(p UserDefaultsAccountPermissions) users.Permissions {
func ConvertPermissionsToUsers(p UserDefaultsPermissions) users.Permissions {
return users.Permissions{
Api: p.Api,
Admin: p.Admin,
@@ -57,14 +57,10 @@ func GetSettingsConfig(nameType string, Value string) string {
func AdminPerms() users.Permissions {
return users.Permissions{
Modify: true,
Share: true,
Admin: true,
Api: true,
Download: true,
Delete: true,
Create: true,
Realtime: false,
Modify: true,
Share: true,
Admin: true,
Api: true,
}
}
@@ -74,100 +70,70 @@ func AdminPerms() users.Permissions {
func ApplyUserDefaults(u *users.User) {
d := Config.UserDefaults
// Account settings
u.DisableSettings = d.Account.DisableSettings
u.LockPassword = d.Account.LockPassword
u.DisableUpdateNotifications = d.Account.DisableUpdateNotifications
u.DisableSettings = d.DisableSettings
u.LockPassword = d.LockPassword
// 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)
u.EditorQuickSave = d.EditorQuickSave
u.HideSidebarFileActions = d.HideSidebarFileActions
u.DisableQuickToggles = d.DisableQuickToggles
u.DisableSearchOptions = d.DisableSearchOptions
u.StickySidebar = d.StickySidebar
u.HideFilesInTree = d.HideFilesInTree
u.DarkMode = boolValueOrDefault(d.DarkMode, true)
u.Locale = d.Locale
u.ViewMode = d.ViewMode
u.SingleClick = d.SingleClick
u.ShowHidden = d.ShowHidden
u.DateFormat = d.DateFormat
u.GallerySize = d.GallerySize
u.ThemeColor = d.ThemeColor
u.QuickDownload = d.QuickDownload
u.DisablePreviewExt = d.DisablePreviewExt
u.DisableViewingExt = d.DisableViewingExt
u.DisableUpdateNotifications = d.DisableUpdateNotifications
u.DisableOfficePreviewExt = d.DisableOfficePreviewExt
u.DisableOnlyOfficeExt = d.DisableOnlyOfficeExt
u.CustomTheme = d.CustomTheme
u.ShowSelectMultiple = d.ShowSelectMultiple
u.DebugOffice = d.DebugOffice
u.DeleteWithoutConfirming = d.DeleteWithoutConfirming
u.DeleteAfterArchive = d.DeleteAfterArchive
u.PreferEditorForMarkdown = d.PreferEditorForMarkdown
u.FileLoading = d.FileLoading
// 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
u.Permissions.Api = d.Permissions.Api
u.Permissions.Admin = d.Permissions.Admin
u.Permissions.Modify = d.Permissions.Modify
u.Permissions.Share = d.Permissions.Share
u.Permissions.Realtime = d.Permissions.Realtime
u.Permissions.Delete = d.Permissions.Delete
u.Permissions.Create = d.Permissions.Create
u.Permissions.Download = boolValueOrDefault(d.Permissions.Download, true)
// Preview settings
u.Preview.DisableHideSidebar = d.Sidebar.DisableHideOnPreview
u.Preview.DisableHideSidebar = d.Preview.DisableHideSidebar
u.Preview.Image = boolValueOrDefault(d.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(d.Preview.Video, true)
u.Preview.Audio = boolValueOrDefault(d.Preview.Audio, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(d.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(d.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(d.Preview.PopUp, true)
u.Preview.AutoplayMedia = boolValueOrDefault(d.Preview.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = d.Preview.DefaultMediaPlayer
u.Preview.Folder = boolValueOrDefault(d.Preview.Folder, true)
u.Preview.Models = boolValueOrDefault(d.Preview.Models, true)
u.DisablePreviewExt = d.Preview.DisablePreviewExt
// FileViewer settings
u.EditorQuickSave = d.FileViewer.EditorQuickSave
u.Preview.AutoplayMedia = boolValueOrDefault(d.FileViewer.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = d.FileViewer.DefaultMediaPlayer
u.DisableViewingExt = d.FileViewer.DisableViewingExt
u.DisableOnlyOfficeExt = d.FileViewer.DisableOnlyOfficeExt
u.DisableOfficePreviewExt = d.FileViewer.DisableOnlyOfficeExt // deprecated field, map to same source
u.PreferEditorForMarkdown = d.FileViewer.PreferEditorForMarkdown
u.DebugOffice = d.FileViewer.DebugOffice
// Search settings
u.DisableSearchOptions = d.Search.DisableOptions
// UI settings
u.DarkMode = boolValueOrDefault(d.UI.DarkMode, true)
u.ThemeColor = d.UI.ThemeColor
u.CustomTheme = d.UI.CustomTheme
u.Locale = d.UI.Locale
// FileLoading settings
u.FileLoading = d.FileLoading
if u.Username == "anonymous" {
return
if u.LoginMethod == "" && d.LoginMethod != "" {
u.LoginMethod = users.LoginMethod(d.LoginMethod)
}
// 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 {
if len(u.Scopes) == 0 && u.Username != "anonymous" {
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.UI.Locale == "" {
set.UserDefaults.UI.Locale = "en"
if set.UserDefaults.Locale == "" {
set.UserDefaults.Locale = "en"
}
if set.UserDefaults.Listing.ViewMode == "" {
set.UserDefaults.Listing.ViewMode = "normal"
if set.UserDefaults.ViewMode == "" {
set.UserDefaults.ViewMode = "normal"
}
err := s.back.Save(set)
+56 -150
View File
@@ -20,14 +20,6 @@ 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 {
@@ -49,8 +41,8 @@ type Environment struct {
type Server struct {
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is 4.
Socket string `json:"socket"` // socket to listen on - eg. /var/run/filebrowser.sock
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
@@ -199,9 +191,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.
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'
Utc bool `json:"utc" yaml:"utc"` // use UTC time in the output instead of local time
}
type Source struct {
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
Name string `json:"name"` // display name
@@ -211,14 +203,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"` // create a user directory for each user under defaultUserScope + username
CreateUserDir bool `json:"createUserDir"` // deprecated: create a user directory for each user under defaultUserScope + username (always true now)
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:"-"`
@@ -316,99 +308,24 @@ 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
@@ -419,51 +336,40 @@ type UserDefaultsAccountPermissions 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 {
// 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
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"`
DefaultScopes []users.SourceScope `json:"-"`
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
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.
}
-28
View File
@@ -3,29 +3,11 @@ 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 = ""
@@ -62,13 +44,3 @@ 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
}
-25
View File
@@ -57,9 +57,6 @@ type FileOptions struct {
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
ShowHidden bool // whether to show hidden files (true = show, false = hide)
ShowPinnedItems bool // whether to show pinned items
ShareHash string // when set, pinned items are loaded from this share link
HideFileExt string // Hide files based on extensions (eg: '.lrc' or '.srt')
FollowSymlinks bool // whether to follow symlinks
Only string // whether to only get files or folders
SkipExtendedAttrs bool // whether to skip extended attributes
@@ -89,25 +86,3 @@ 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
}
+6 -5
View File
@@ -1,10 +1,11 @@
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
+38 -69
View File
@@ -5,82 +5,51 @@ server:
- levels: "info|warning|error"
sources:
- path: "/srv"
auth:
methods:
password:
enabled: true
minLength: 5
signup: false
passkey:
enabled: false
userDefaults:
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
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
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)
disablePreviewExt: "" # space separated list of file extensions to disable preview for
highQuality: true # high quality preview thumbnails
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
defaultMediaPlayer: false # disable html5 media player and use the default media player
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
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
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
-6
View File
@@ -17,11 +17,6 @@ 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"`
}
@@ -36,5 +31,4 @@ 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
}
-80
View File
@@ -1,80 +0,0 @@
package share
import (
"fmt"
"slices"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
)
// PinnedItems maps share-relative directory paths to pinned item names.
type PinnedItems map[string][]string
// Add records a pinned item name under shareRelDir.
func (p PinnedItems) Add(shareRelDir, name string) {
if p == nil {
return
}
items := p[shareRelDir]
if slices.Contains(items, name) {
return
}
p[shareRelDir] = append(items, name)
}
// Remove deletes a pinned item name and prunes empty map entries.
func (p PinnedItems) Remove(shareRelDir, name string) {
if p == nil {
return
}
items := p[shareRelDir]
idx := slices.Index(items, name)
if idx < 0 {
return
}
items = append(items[:idx], items[idx+1:]...)
if len(items) == 0 {
delete(p, shareRelDir)
} else {
p[shareRelDir] = items
}
}
// NamesForDirectory returns pinned item names for a share-relative directory path.
func (p PinnedItems) NamesForDirectory(shareRelDir string) []string {
if len(p) == 0 {
return nil
}
names := p[shareRelDir]
if len(names) == 0 {
return nil
}
out := make([]string, len(names))
copy(out, names)
return out
}
// EnsurePinnedItems returns a non-nil map for mutation.
func (l *Link) EnsurePinnedItems() PinnedItems {
if l.PinnedItems == nil {
l.PinnedItems = make(PinnedItems)
}
return l.PinnedItems
}
// ShareRelativeDir maps a source index directory path to a share-relative directory path.
// link.Path is the index path of the share root (e.g. "/share/").
func (l *Link) ShareRelativeDir(indexDirPath string) (string, error) {
shareRootIndex := utils.AddTrailingSlashIfNotExists(l.Path)
indexDirPath = utils.AddTrailingSlashIfNotExists(indexDirPath)
if !strings.HasPrefix(indexDirPath, shareRootIndex) {
return "", fmt.Errorf("path is outside share scope")
}
rel := strings.TrimPrefix(indexDirPath, shareRootIndex)
if rel == "" {
return "/", nil
}
return utils.AddTrailingSlashIfNotExists("/" + strings.TrimPrefix(rel, "/")), nil
}
@@ -1,58 +0,0 @@
package share
import "testing"
func TestSharePinnedItemsAddRemove(t *testing.T) {
p := make(PinnedItems)
const dir = "/"
const name = "vacation.jpg"
p.Add(dir, name)
if len(p[dir]) != 1 || p[dir][0] != name {
t.Fatalf("add failed: %#v", p)
}
p.Add(dir, name)
if len(p[dir]) != 1 {
t.Fatalf("duplicate add should be ignored: %#v", p)
}
p.Remove(dir, name)
if len(p) != 0 {
t.Fatalf("remove should prune empty entries: %#v", p)
}
}
func TestSharePinnedNamesForDirectory(t *testing.T) {
p := PinnedItems{
"/photos/": {"vacation.jpg", "notes.txt"},
}
names := p.NamesForDirectory("/photos/")
if len(names) != 2 || names[0] != "vacation.jpg" || names[1] != "notes.txt" {
t.Fatalf("unexpected names: %#v", names)
}
if got := p.NamesForDirectory("/missing/"); got != nil {
t.Fatalf("expected nil for missing directory, got %#v", got)
}
}
func TestShareRelativeDir(t *testing.T) {
link := &Link{CommonShare: CommonShare{Path: "/share/"}}
got, err := link.ShareRelativeDir("/share/")
if err != nil {
t.Fatalf("ShareRelativeDir root: %v", err)
}
if got != "/" {
t.Fatalf("got %q, want /", got)
}
got, err = link.ShareRelativeDir("/share/test [file2]/")
if err != nil {
t.Fatalf("ShareRelativeDir subdir: %v", err)
}
if got != "/test [file2]/" {
t.Fatalf("got %q, want /test [file2]/", got)
}
}
+7 -9
View File
@@ -43,9 +43,8 @@ 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)
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)
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
SourceURL string `json:"sourceURL,omitempty"`
CanEditShare bool `json:"canEditShare,omitempty"`
}
@@ -60,12 +59,11 @@ type CreateBody struct {
// Link is the information needed to build a shareable link.
type Link struct {
CommonShare
Downloads int `json:"downloads"`
Hash string `json:"hash" storm:"id,index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
PinnedItems PinnedItems `json:"pinnedItems,omitempty"`
Downloads int `json:"downloads"`
Hash string `json:"hash" storm:"id,index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
// Token is a random value that will only be set when PasswordHash is set. It is
// URL-Safe and is used to download links in password-protected shares via a
// query arg.
+82 -87
View File
@@ -298,70 +298,28 @@ 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()
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked): %v", err)
return false, true, nil
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()
return false, false, err
db.mu.Unlock() // Release mutex on error
return err // Hard failure: something is wrong with the DB
}
defer db.mu.Unlock() // Ensure mutex is always released
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
db.mu.Unlock()
}()
// Always update all fields including last_updated, even if nothing changed
// This ensures last_updated stays current for stale entry detection
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -378,12 +336,14 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
`)
if err != nil {
if isBusyError(err) || isTransactionError(err) {
logger.Debugf("[DB_TX] BulkInsertItems: Prepare busy/locked: %v", err)
return false, true, nil
// 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
}
return false, false, err
return err
}
defer stmt.Close()
defer stmt.Close() // Ensure statement is always closed
nowUnix := time.Now().Unix()
for _, info := range items {
@@ -403,23 +363,28 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
)
if err != nil {
if isBusyError(err) || isTransactionError(err) {
logger.Debugf("[DB_TX] BulkInsertItems: Exec busy/locked during insert: %v", err)
return false, true, nil
// 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.Errorf("[DB] BulkInsertItems: Exec failed for path=%s, source=%s, error=%v", info.Path, source, err)
return false, false, err
return err
}
}
// Try to commit
if err := tx.Commit(); err != nil {
if isBusyError(err) || isTransactionError(err) {
logger.Debugf("[DB_TX] BulkInsertItems: Commit busy/locked: %v", err)
return false, true, nil
// 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
}
return false, false, err
return err
}
committed = true
return true, false, nil
return nil
}
// isBusyError checks if an error is SQLITE_BUSY (error code 5)
@@ -509,6 +474,51 @@ 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 := `
@@ -1067,31 +1077,17 @@ func (db *IndexDB) UpdateFolderSizesIfChanged(source string, pathSizes map[strin
args = append(args, path)
}
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
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
}
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
return 0, err
}
return 0, nil // defensive: unreachable when dbTransientOpAttempts >= 1
rowsAffected, _ := result.RowsAffected()
return rowsAffected, nil
}
// LoadFolderSizes loads all folder sizes for a source from the database
@@ -1324,7 +1320,6 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
return nil, err
}
info.ModTime = time.Unix(modTime, 0)
info.IsDir = isDir
return &info, nil
}
+2 -33
View File
@@ -18,31 +18,9 @@ type SearchItem struct {
HasPreview bool
}
// appendNameGlobSQL adds AND (name GLOB ? OR ...) or AND (name GLOB ? AND ...) when joinAnd is true.
func appendNameGlobSQL(query string, args []interface{}, patterns []string, joinAnd, caseExact bool) (string, []interface{}) {
if len(patterns) == 0 {
return query, args
}
nameExpr := "name"
if !caseExact {
nameExpr = "lower(name)"
}
parts := make([]string, len(patterns))
for i := range patterns {
parts[i] = nameExpr + " GLOB ?"
args = append(args, patterns[i])
}
join := " OR "
if joinAnd {
join = " AND "
}
return query + " AND (" + strings.Join(parts, join) + ")", args
}
// SearchItems queries the database for items matching the search criteria for a single source.
// When nameGlobPatterns is non-empty (and largest is false), rows are restricted with SQLite name GLOB ... OR ....
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd, caseExact bool) (*sql.Rows, error) {
func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.Rows, error) {
query := `
SELECT path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
@@ -57,10 +35,6 @@ func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGl
args = append(args, scope+"*")
}
if !largest && len(nameGlobPatterns) > 0 {
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd, caseExact)
}
if largest {
query += " ORDER BY size DESC"
}
@@ -69,9 +43,8 @@ func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGl
}
// 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, nameGlobPatterns []string, nameGlobPatternsAnd, caseExact bool) (*sql.Rows, error) {
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool) (*sql.Rows, error) {
if len(sources) == 0 {
return nil, fmt.Errorf("at least one source is required")
}
@@ -122,10 +95,6 @@ func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[str
query += " WHERE " + strings.Join(whereClauses, " AND ")
}
if !largest && len(nameGlobPatterns) > 0 {
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd, caseExact)
}
if largest {
query += " ORDER BY size DESC"
}
+1 -1
View File
@@ -21,7 +21,7 @@ func (s settingsBackend) Save(set *settings.Settings) error {
func (s settingsBackend) GetServer() (*settings.Server, error) {
server := &settings.Server{
Port: 80,
NumImageProcessors: 4,
NumImageProcessors: 1,
}
return server, get(s.db, "server", server)
}
+2 -2
View File
@@ -251,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.Pointer {
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
t := v.Type()
@@ -270,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.Pointer {
if embeddedType.Kind() == reflect.Ptr {
embeddedType = embeddedType.Elem()
}
+4 -4
View File
@@ -492,13 +492,13 @@ func TestFilterRestrictedFields(t *testing.T) {
}{
{
name: "all allowed fields",
fields: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode", "PinnedItems"},
expected: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode", "PinnedItems"},
fields: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
expected: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
},
{
name: "mixed allowed and restricted",
fields: []string{"DarkMode", "Permissions", "Scopes", "OtpEnabled", "PinnedItems"},
expected: []string{"DarkMode", "OtpEnabled", "PinnedItems"},
fields: []string{"DarkMode", "Permissions", "Scopes", "OtpEnabled"},
expected: []string{"DarkMode", "OtpEnabled"},
},
{
name: "all restricted fields",
+8 -3
View File
@@ -92,10 +92,16 @@ 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)
@@ -124,7 +130,6 @@ 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)
+63 -38
View File
@@ -5,23 +5,6 @@ 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 {
@@ -41,9 +24,10 @@ func (u *User) GetSourceNames() []string {
return sources
}
// 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.
// GetBackendScopes converts the user's scopes from frontend-style to backend-style
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
}
@@ -53,8 +37,24 @@ func (u *User) GetBackendScopes() ([]SourceScope, error) {
newScopes := []SourceScope{}
for _, scope := range u.Scopes {
source, ok := ResolveSourceKey(scope.Name)
// 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)
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,
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
}
return newScopes, nil
}
// GetFrontendScopes converts scopes for JSON clients: SourceScope.Name is always the source display name.
// Assumes Bolt stores paths (GetBackendScopes); unknown keys are omitted.
// GetFrontendScopes converts the user's scopes from backend-style to frontend-style
// Backend scopes use source paths, frontend scopes use source names
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 {
source, ok := ResolveSourceKey(scope.Name)
if !ok {
continue
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,
})
}
newScopes = append(newScopes, SourceScope{
Name: source.Name,
Scope: scope.Scope,
})
}
return newScopes
}
// GetBackendSidebarLinks normalizes source links for Bolt: SourceName is the filesystem path.
// GetBackendSidebarLinks converts the user's sidebar links from frontend-style to backend-style
// Validates that sources exist and converts source names to paths
func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
if sourceConfig == nil {
return nil, fmt.Errorf("source config not initialized")
@@ -98,27 +98,52 @@ 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)
sourceInfo2, ok2 := sourceConfig.GetSourceByPath(link.SourceName)
if !ok && !ok2 {
if !ok {
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
}
if ok {
link.SourceName = sourceInfo.Path
} else {
link.SourceName = sourceInfo2.Path
}
link.SourceName = sourceInfo.Path // if name changes keep link alive
}
// 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, "/") {
-69
View File
@@ -1,69 +0,0 @@
package users
import "slices"
// Add records a pinned item name under sourcePath and indexDirPath.
func (p PinnedItems) Add(sourcePath, indexDirPath, name string) {
if p == nil {
return
}
if p[sourcePath] == nil {
p[sourcePath] = make(map[string][]string)
}
items := p[sourcePath][indexDirPath]
if slices.Contains(items, name) {
return
}
p[sourcePath][indexDirPath] = append(items, name)
}
// Remove deletes a pinned item name and prunes empty map entries.
func (p PinnedItems) Remove(sourcePath, indexDirPath, name string) {
if p == nil {
return
}
byDir, ok := p[sourcePath]
if !ok {
return
}
items := byDir[indexDirPath]
idx := slices.Index(items, name)
if idx < 0 {
return
}
items = append(items[:idx], items[idx+1:]...)
if len(items) == 0 {
delete(byDir, indexDirPath)
} else {
byDir[indexDirPath] = items
}
if len(byDir) == 0 {
delete(p, sourcePath)
}
}
// PinnedNamesForDirectory returns pinned item names for a source/index directory path.
func (u *User) PinnedNamesForDirectory(sourcePath, indexDirPath string) []string {
if u == nil || len(u.PinnedItems) == 0 {
return nil
}
byDir, ok := u.PinnedItems[sourcePath]
if !ok {
return nil
}
names := byDir[indexDirPath]
if len(names) == 0 {
return nil
}
out := make([]string, len(names))
copy(out, names)
return out
}
// EnsurePinnedItems returns a non-nil map for mutation.
func (u *User) EnsurePinnedItems() PinnedItems {
if u.PinnedItems == nil {
u.PinnedItems = make(PinnedItems)
}
return u.PinnedItems
}
@@ -1,98 +0,0 @@
package users
import "testing"
func TestPinnedItemsAddRemove(t *testing.T) {
p := make(PinnedItems)
const source = "/data/files"
const dir = "/photos/"
const name = "vacation.jpg"
p.Add(source, dir, name)
if len(p[source][dir]) != 1 || p[source][dir][0] != name {
t.Fatalf("add failed: %#v", p)
}
p.Add(source, dir, name)
if len(p[source][dir]) != 1 {
t.Fatalf("duplicate add should be ignored: %#v", p)
}
p.Remove(source, dir, name)
if len(p) != 0 {
t.Fatalf("remove should prune empty maps: %#v", p)
}
}
func TestPinnedNamesForDirectory(t *testing.T) {
u := &User{
NonAdminEditable: NonAdminEditable{
PinnedItems: PinnedItems{
"/data/files": {
"/photos/": {"vacation.jpg", "notes.txt"},
},
},
},
}
names := u.PinnedNamesForDirectory("/data/files", "/photos/")
if len(names) != 2 || names[0] != "vacation.jpg" || names[1] != "notes.txt" {
t.Fatalf("unexpected names: %#v", names)
}
if got := u.PinnedNamesForDirectory("/missing", "/photos/"); got != nil {
t.Fatalf("expected nil for missing source, got %#v", got)
}
}
func TestPinnedItemsRemoveMissing(t *testing.T) {
p := make(PinnedItems)
p.Remove("/data/files", "/photos/", "vacation.jpg")
if len(p) != 0 {
t.Fatalf("remove on empty map should be no-op: %#v", p)
}
}
func TestPinnedItemsMultipleItems(t *testing.T) {
p := make(PinnedItems)
const source = "/data/files"
const dir = "/photos/"
p.Add(source, dir, "a.jpg")
p.Add(source, dir, "b.jpg")
p.Add(source, dir, "c.jpg")
if len(p[source][dir]) != 3 {
t.Fatalf("expected 3 items, got %d", len(p[source][dir]))
}
p.Remove(source, dir, "b.jpg")
items := p[source][dir]
if len(items) != 2 || items[0] != "a.jpg" || items[1] != "c.jpg" {
t.Fatalf("unexpected items after remove: %#v", items)
}
}
func TestPinnedItemsMultipleDirectories(t *testing.T) {
p := make(PinnedItems)
const source = "/data/files"
const photosDir = "/photos/"
const docsDir = "/docs/"
p.Add(source, photosDir, "a.jpg")
p.Add(source, docsDir, "notes.txt")
if len(p[source][photosDir]) != 1 || p[source][photosDir][0] != "a.jpg" {
t.Fatalf("unexpected photos dir: %#v", p[source][photosDir])
}
if len(p[source][docsDir]) != 1 || p[source][docsDir][0] != "notes.txt" {
t.Fatalf("unexpected docs dir: %#v", p[source][docsDir])
}
p.Remove(source, photosDir, "a.jpg")
if _, ok := p[source][photosDir]; ok {
t.Fatalf("photos dir should be pruned: %#v", p)
}
if len(p[source][docsDir]) != 1 {
t.Fatalf("docs dir should remain: %#v", p[source][docsDir])
}
}
+39 -82
View File
@@ -7,8 +7,7 @@ import (
jwt "github.com/golang-jwt/jwt/v4"
)
// CurrentUserMigrationVersion is persisted for newly created accounts and after legacy migrations finish.
const CurrentUserMigrationVersion = 3
const ()
type LoginMethod string
@@ -20,35 +19,6 @@ 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
@@ -96,11 +66,7 @@ type Preview struct {
Models bool `json:"models"` // show live thumbnails for 3D models files
}
// source filesystem path -> index directory path -> pinned item names (unique within the directory)
type PinnedItems map[string]map[string][]string
// User describes a persisted account. Scopes and source sidebar links store filesystem paths in Name/SourceName;
// JSON responses use display names only (see http prepForFrontend).
// User describes a user.
type User struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
@@ -122,50 +88,46 @@ type User struct {
}
type SourceScope struct {
Name string `json:"name"` // Bolt: filesystem path; JSON API: display name after prepForFrontend
Scope string `json:"scope"` // index path within that source
Name string `json:"name"`
Scope string `json:"scope"`
}
// json tags must match variable name with smaller case first letter
type NonAdminEditable struct {
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
Preview Preview `json:"preview"`
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
ShowFirstLogin bool `json:"showFirstLogin"`
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
PinnedItems PinnedItems `json:"pinnedItems,omitempty"`
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
Preview Preview `json:"preview"`
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
ShowFirstLogin bool `json:"showFirstLogin"`
}
type FileLoading struct {
@@ -181,7 +143,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"` // Bolt: filesystem path. JSON out: display name (after prepForFrontend).
SourceName string `json:"sourceName,omitempty"` // Source identifier for source-type links
}
func CleanUsername(s string) string {
@@ -228,11 +190,6 @@ 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
@@ -1,127 +0,0 @@
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()
}
+63 -75
View File
@@ -4,42 +4,40 @@ go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.20.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.28.2
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.4
github.com/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/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.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.48
github.com/kovidgoyal/imaging v1.8.21
github.com/mattn/go-sqlite3 v1.14.42
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.54.0
golang.org/x/image v0.44.0
golang.org/x/mod v0.38.0
golang.org/x/net v0.57.0
golang.org/x/crypto v0.50.0
golang.org/x/image v0.39.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0
golang.org/x/sys v0.43.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.48.0
golang.org/x/tools v0.44.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.54.0
modernc.org/sqlite v1.50.0
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
charm.land/lipgloss/v2 v2.0.3 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
@@ -54,14 +52,13 @@ require (
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.65.3 // indirect
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
github.com/air-verse/air v1.65.1 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
@@ -69,67 +66,64 @@ 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.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // 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/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.8.0 // indirect
github.com/bombsimon/wsl/v5 v5.6.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.0 // 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.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/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/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.12.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/spec v0.22.6 // indirect
github.com/go-openapi/swag/conv v0.27.0 // indirect
github.com/go-openapi/swag/jsonname v0.27.0 // indirect
github.com/go-openapi/swag/jsonutils v0.27.0 // indirect
github.com/go-openapi/swag/loading v0.27.0 // indirect
github.com/go-openapi/swag/stringutils v0.27.0 // indirect
github.com/go-openapi/swag/typeutils v0.27.0 // indirect
github.com/go-openapi/swag/yamlutils v0.27.0 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -140,28 +134,24 @@ require (
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260713102120-857a528af641 // indirect
github.com/golang/geo v0.0.0-20260415063119-550b242b3150 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.12.2 // indirect
github.com/golangci/golangci-lint/v2 v2.11.4 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/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
@@ -169,15 +159,17 @@ 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.9.0 // indirect
github.com/hashicorp/go-version v1.8.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.10.0 // indirect
github.com/jgautheron/goconst v1.8.2 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.6.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
@@ -194,32 +186,31 @@ 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.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.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.6.0 // indirect
github.com/manuelarte/funcorder v0.5.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.23 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.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/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // 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.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // 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
@@ -237,17 +228,16 @@ 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.26.1 // indirect
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
@@ -259,18 +249,16 @@ 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.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
github.com/tetafro/godot v1.5.4 // indirect
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // 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.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/uudashr/iface v1.4.1 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
@@ -278,22 +266,22 @@ require (
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go-simpler.org/sloglint v0.11.1 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.74.2 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+168 -168
View File
@@ -2,8 +2,6 @@
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=
@@ -69,28 +67,26 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
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.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
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.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.65.3 h1:7NUAqofZuSf5vZbVoZpdTr/4LuKkOCj1JTiMrOdUs7A=
github.com/air-verse/air v1.65.3/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.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
@@ -118,10 +114,12 @@ 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.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/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/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=
@@ -162,8 +160,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs=
github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o=
github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -171,8 +169,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.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I=
github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4=
github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E=
github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70=
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
@@ -186,18 +184,16 @@ 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.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/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/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=
@@ -206,13 +202,9 @@ 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.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@@ -235,8 +227,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.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/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=
@@ -256,8 +248,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -277,22 +269,20 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/go-fitz v1.28.2 h1:845G85N5TUgnq5oDqyYrW0JvehAkeo35UkkK2dJtW1M=
github.com/gen2brain/go-fitz v1.28.2/go.mod h1:pY2hqAjp9Zy7qfPI2gwbJMHBFAdZpVXOLrRxD82l3Bs=
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -309,48 +299,48 @@ github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic=
github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM=
github.com/go-openapi/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.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8=
github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8=
github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE=
github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk=
github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE=
github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0 h1:+d7C7Ur/SsGg/UZ9G0JEovnfRqtMNZCJQGKc2h/ojoE=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI=
github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4=
github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio=
github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o=
github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM=
github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -377,10 +367,6 @@ github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUN
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@@ -412,13 +398,11 @@ 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-20260713102120-857a528af641 h1:cpoobkgVGCE6bC5kJkOfwfM6hAtZuvoBjnCLECxG4AU=
github.com/golang/geo v0.0.0-20260713102120-857a528af641/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260415063119-550b242b3150 h1:F5CQANtNulclG+xJt370ohUujAEHq2XNBBAjkoSukQI=
github.com/golang/geo v0.0.0-20260415063119-550b242b3150/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -452,14 +436,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-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A=
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
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/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.12.2 h1:7+d1uY0bq1MU2UV3R5pW5Q7QWdcoq4naMRXM+gsJKrs=
github.com/golangci/golangci-lint/v2 v2.12.2/go.mod h1:opqHHuIcTG2R+4akzWMd4o1BnD9/1LcjICWOujr91U8=
github.com/golangci/golangci-lint/v2 v2.11.4 h1:GK+UlZBN5y7rh2PBnHA93XLSX6RaF7uhzJQ3JwU1wuA=
github.com/golangci/golangci-lint/v2 v2.11.4/go.mod h1:ODQDCASMA3VqfZYIbbQLpTRTzV7O/vjmIRF6u8NyFwI=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
@@ -468,8 +452,6 @@ 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=
@@ -489,10 +471,6 @@ 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=
@@ -537,8 +515,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.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
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=
@@ -566,8 +544,10 @@ 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.10.0 h1:Ptt+OoE4NaEWKhLrWrrN3IpZdGLiqaf7WLnEX/iv4Jw=
github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc=
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/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=
@@ -585,6 +565,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
@@ -598,8 +580,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
github.com/kovidgoyal/imaging v1.8.23 h1:4WsboyQ/E8tic2kX49P93Rvscg33SPsCwTBG5EZZF54=
github.com/kovidgoyal/imaging v1.8.23/go.mod h1:k3Iot3H0v2rSWXIxQISfT3e9wIryqzAHLHCuhG7z9lM=
github.com/kovidgoyal/imaging v1.8.21 h1:95S2+dowTeKJJHNpf6lnScvIennTr2H0zQotu+ptNQw=
github.com/kovidgoyal/imaging v1.8.21/go.mod h1:976F+zjiQeZ7sd87Pxlm0a64S/w9bImSIWg3sSk1rdQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -632,8 +614,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.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
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/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=
@@ -644,8 +626,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.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0=
github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g=
github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8=
github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA=
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=
@@ -658,12 +640,12 @@ 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.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/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/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.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
@@ -683,10 +665,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=
@@ -711,8 +693,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.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps=
github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
@@ -726,12 +708,10 @@ 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.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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/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=
@@ -776,6 +756,7 @@ 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=
@@ -788,8 +769,6 @@ 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/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=
@@ -800,10 +779,12 @@ 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.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4=
github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
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=
@@ -814,8 +795,8 @@ github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+W
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY=
github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E=
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
@@ -861,16 +842,14 @@ 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.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
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/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-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0=
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
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/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=
@@ -883,14 +862,12 @@ 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.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
github.com/uudashr/iface v1.4.2 h1:06Vq5RKVYThBsj0Bnw4oasMjD1r+7CE/bcKOA8dVSvg=
github.com/uudashr/iface v1.4.2/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
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=
@@ -907,6 +884,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
@@ -919,15 +897,15 @@ go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4=
go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I=
go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -935,8 +913,6 @@ 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=
@@ -950,8 +926,10 @@ 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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/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 +948,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.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -991,11 +969,14 @@ 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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
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/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=
@@ -1028,14 +1009,18 @@ 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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1057,8 +1042,10 @@ 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1094,7 +1081,9 @@ 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=
@@ -1105,11 +1094,17 @@ 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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/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=
@@ -1118,8 +1113,10 @@ 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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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/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=
@@ -1169,11 +1166,14 @@ 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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1259,8 +1259,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
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=
@@ -1290,20 +1290,20 @@ 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.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.2 h1:r94QPC+Mh3WN1lSCDxQxFdIxcI+zdQROo+1pTEZGqkk=
modernc.org/libc v1.74.2/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -1312,8 +1312,8 @@ 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.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+19 -274
View File
@@ -14,159 +14,17 @@ import (
"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.
//
@@ -704,20 +562,14 @@ func createTarGzWithLevel(d *requestContext, source string, w io.Writer, level i
return nil
}
// 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.
// 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.
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, _, err := idx.GetRealPath(fileList[0])
realPath, isDir, err := idx.GetRealPath(fileList[0])
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get real path for %s: %v", fileList[0], err)
}
@@ -733,142 +585,35 @@ func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusInternalServerError, errors.New("format not implemented")
}
originalFileName := archiveAttachmentStem(fileList, realPath) + extension
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
baseDirName := filepath.Base(filepath.Dir(realPath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
// 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)
}
if len(fileList) == 1 && isDir {
baseDirName = filepath.Base(realPath)
}
originalFileName := baseDirName + extension
// 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")
setContentDisposition(w, r, originalFileName)
w.Header().Set("Content-Type", "application/octet-stream")
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
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())
}
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, tmpF, fileList...)
err = createZip(d, source, writer, fileList...)
} else {
err = createTarGz(d, source, tmpF, fileList...)
err = createTarGz(d, source, writer, fileList...)
}
if err != nil {
_ = tmpF.Close()
_ = os.Remove(tmpPath)
return http.StatusInternalServerError, err
}
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)
return 0, nil
}
// archiveCreateRequest is the body for POST /resources/archive (server-side create).
+10 -15
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,9 +221,7 @@ 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) {
@@ -233,9 +231,6 @@ 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)
}
@@ -322,7 +317,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 !config.Auth.Methods.PasswordAuth.Signup {
if !settings.Config.Auth.Methods.PasswordAuth.Signup {
return http.StatusMethodNotAllowed, fmt.Errorf("signup is disabled")
}
@@ -342,7 +337,7 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
},
LoginMethod: users.LoginMethodPassword,
}
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(config.UserDefaults.Account.Permissions))
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions))
if err != nil {
logger.Debug(err.Error())
// Return the actual error message instead of a generic one
-269
View File
@@ -1,269 +0,0 @@
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)
})
}
+17 -16
View File
@@ -18,14 +18,19 @@ 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{
@@ -53,13 +58,7 @@ func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.rs.Seek(offset, whence)
}
// throttledWriter wraps an io.Writer and rate-limits outbound bytes (streaming archives to the client).
type throttledWriter struct {
w io.Writer
limiter *rate.Limiter
ctx context.Context
}
// newThrottledWriter creates a new throttledWriter.
func newThrottledWriter(w io.Writer, limit rate.Limit, burst int, ctx context.Context) *throttledWriter {
return &throttledWriter{
w: w,
@@ -68,11 +67,13 @@ func newThrottledWriter(w io.Writer, limit rate.Limit, burst int, ctx context.Co
}
}
func (tw *throttledWriter) Write(p []byte) (n int, err error) {
n, err = tw.w.Write(p)
func (w *throttledWriter) Write(p []byte) (n int, err error) {
n, err = w.w.Write(p)
if n > 0 {
if waitErr := tw.limiter.WaitN(tw.ctx, n); waitErr != nil {
if err == nil {
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.
err = waitErr
}
}
@@ -98,8 +99,6 @@ 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
@@ -275,10 +274,12 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
fmt.Sprintf("OnlyOffice Server downloading file: %s", firstFilePath))
}
// ServeContent sets Content-Length (full resource or range); avoid pre-setting so partial/range responses stay consistent.
// Set headers
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
+31 -57
View File
@@ -10,7 +10,6 @@ import (
"io/fs"
"net"
"net/http"
"os"
"time"
_ "net/http/pprof"
@@ -81,40 +80,30 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicRoutes := http.NewServeMux()
publicApi := http.NewServeMux()
// health routes
api.HandleFunc("GET /health", healthHandler)
publicApi.HandleFunc("GET /health", healthHandler)
// ========================================
// User Routes - /api/users/ (with public routes)
// ========================================
api.HandleFunc("GET /users", withUser(userGetHandler))
api.HandleFunc("POST /users", withSelfOrAdmin(usersPostHandler))
api.HandleFunc("PUT /users", withUser(userPutHandler))
api.HandleFunc("PATCH /users/pinnedItems", withUser(userPatchPinnedItemsHandler))
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
publicApi.HandleFunc("GET /users", withUser(userGetHandler))
// ========================================
// Auth Routes - /api/auth/
// ========================================
api.HandleFunc("POST /auth/login", 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)))
api.HandleFunc("POST /auth/login", loginHelper(loginHandler))
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
api.HandleFunc("POST /auth/otp/generate", withOrWithoutUser(generateOTPHandler))
api.HandleFunc("POST /auth/otp/verify", withOrWithoutUser(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))
// ========================================
// Resources Routes - /api/resources/ (with public routes)
@@ -129,7 +118,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("POST /resources/archive", withUser(archiveCreateHandler))
api.HandleFunc("POST /resources/unarchive", withUser(unarchiveHandler))
api.HandleFunc("GET /resources/download", withUser(downloadHandler))
api.HandleFunc("GET /resources/preview", withTimeout(30*time.Second, withUserHelper(previewHandler)))
api.HandleFunc("GET /resources/preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
api.HandleFunc("POST /resources/pause", withUser(resourcePauseHandler))
publicApi.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
publicApi.HandleFunc("GET /resources/items", withHashFile(publicItemsGetHandler))
@@ -139,7 +128,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicApi.HandleFunc("DELETE /resources/bulk", withHashFile(publicBulkDeleteHandler))
publicApi.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
publicApi.HandleFunc("GET /resources/download", withHashFile(publicDownloadHandler))
publicApi.HandleFunc("GET /resources/preview", withTimeout(30*time.Second, withHashFileHelper(publicPreviewHandler)))
publicApi.HandleFunc("GET /resources/preview", withHashFile(publicPreviewHandler))
publicApi.HandleFunc("POST /resources/pause", withHashFile(publicPauseHandler))
// Legacy routes (backwards compatibility)
api.HandleFunc("GET /raw", withUser(downloadHandler))
@@ -161,12 +150,11 @@ 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", withUser(shareGetHandler))
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
api.HandleFunc("PATCH /share", withPermShare(sharePatchHandler))
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
publicApi.HandleFunc("GET /share/info", withOrWithoutUser(shareInfoHandler))
publicApi.HandleFunc("PATCH /share/pinnedItems", withPermShare(sharePatchPinnedItemsHandler))
publicApi.HandleFunc("GET /share/image", withHashFile(getShareImage))
// ========================================
@@ -189,9 +177,7 @@ 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)
@@ -206,6 +192,7 @@ 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)
@@ -256,28 +243,16 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
})
}
var scheme string
port := ""
srv := &http.Server{
Addr: fmt.Sprintf("%v:%v", config.Server.ListenAddress, config.Server.Port),
Handler: muxWithMiddleware(router),
}
listenAddress := config.Server.ListenAddress
go func() {
var listener net.Listener
if config.Server.Socket != "" {
if err := os.Remove(config.Server.Socket); err != nil && !os.IsNotExist(err) {
logger.Fatalf("Could not remove existing socket: %v", err)
}
var err error
listener, err = net.Listen("unix", config.Server.Socket)
if err != nil {
logger.Fatalf("Could not listen on unix socket: %v", err)
}
logger.Infof("Running at : unix://%s%s", config.Server.Socket, config.Server.BaseURL)
} else if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
var scheme string
port := ""
// Determine whether to use HTTPS (TLS) or HTTP
// Determine whether to use HTTPS (TLS) or HTTP
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
// Load the TLS certificate and key
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
if err != nil {
@@ -301,6 +276,8 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
logger.Infof("Running at : %s", fullURL)
// Attempt to get listener from socket activation with TLS configuration
var listener net.Listener
socketActivationListeners, err := activation.TLSListeners(tlsConfig)
if err == nil && len(socketActivationListeners) > 0 {
listener = socketActivationListeners[0]
@@ -319,9 +296,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
}
}
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatalf("Server error: %v", err)
}
} else {
var scheme string
port := ""
// Set HTTP scheme and the default port for HTTP
scheme = "http"
if config.Server.Port != 80 {
@@ -332,6 +310,8 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
logger.Infof("Running at : %s", fullURL)
var listener net.Listener
// Attempt to get the listener from socket activation
socketActivationListeners, err := activation.Listeners()
if err == nil && len(socketActivationListeners) > 0 {
@@ -357,10 +337,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
}
}
}
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatalf("Server error: %v", err)
// Start HTTP server
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatalf("Server error: %v", err)
}
}
}()
@@ -399,12 +379,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
logger.Info("HTTP server shut down gracefully.")
}
if config.Server.Socket != "" {
if err := os.Remove(config.Server.Socket); err != nil && !os.IsNotExist(err) {
logger.Debugf("Could not remove unix socket: %v", err)
}
}
// Signal that shutdown is complete
close(shutdownComplete)
}
+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 := config.Auth.Methods.LdapAuth
ldapCfg := settings.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 := config.Auth.Methods.LdapAuth
c := settings.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.Account.Permissions.Admin
isAdmin = config.UserDefaults.Permissions.Admin
}
user = &users.User{
Username: username,
+40 -127
View File
@@ -2,16 +2,17 @@ package http
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// subtitlesHandler handles subtitle requests for both external files and embedded streams
@@ -36,49 +37,58 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
name := r.URL.Query().Get("name")
embedded := r.URL.Query().Get("embedded") == "true"
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
if name == "" {
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: false,
}, store.Access, d.user, store.Share)
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return errToStatus(err), err
}
if !strings.HasPrefix(fileInfo.Type, "video") {
return http.StatusNotFound, fmt.Errorf("file is not a video")
return http.StatusForbidden, err
}
track := findSubtitleTrack(fileInfo.Subtitles, name, embedded)
if track == nil {
return http.StatusNotFound, fmt.Errorf("subtitle track '%s' not found", name)
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, fmt.Errorf("file not found: %v", err)
}
parentDir := filepath.Dir(realPath)
var content string
if !embedded {
subtitlePath := filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
content, err = utils.GetSubtitleSidecarContent(subtitlePath)
content, err = utils.GetSubtitleSidecarContent(filepath.Join(parentDir, name))
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
}
} else {
if track.Index == nil {
// For embedded subtitles, we need to find the stream index by name
// Get file modification time for caching
fileInfo, err := os.Stat(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to stat file: %v", err)
}
// Detect embedded subtitles
embeddedSubs := ffmpeg.DetectEmbeddedSubtitles(realPath, fileInfo.ModTime())
// Find the subtitle track by name
var streamIndex *int
for _, sub := range embeddedSubs {
if sub.Name == name {
streamIndex = sub.Index
break
}
}
if streamIndex == nil {
return http.StatusNotFound, fmt.Errorf("embedded subtitle track '%s' not found", name)
}
content, err = ffmpeg.ExtractSubtitleContent(fileInfo.RealPath, *track.Index)
content, err = ffmpeg.ExtractSubtitleContent(realPath, *streamIndex)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
@@ -92,16 +102,6 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusOK, nil
}
func findSubtitleTrack(subtitles []utils.SubtitleTrack, name string, embedded bool) *utils.SubtitleTrack {
for i := range subtitles {
sub := &subtitles[i]
if sub.Name == name && sub.Embedded == embedded {
return sub
}
}
return nil
}
// metadataHandler returns the same directory resource shape as GET /api/resources with metadata enabled,
// for client-side patching after a fast listing load.
// @Summary Directory with media metadata
@@ -128,9 +128,8 @@ func metadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
Content: false,
Metadata: true,
AlbumArt: albumArt,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: false,
ShowSharedAttr: true,
}, store.Access, d.user, store.Share)
@@ -166,9 +165,8 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestCon
Content: false,
Metadata: true,
AlbumArt: albumArt,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
ExtractEmbeddedSubtitles: settings.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 {
@@ -177,88 +175,3 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestCon
fileInfo.Path = utils.AddTrailingSlashIfNotExists(path)
return renderJSON(w, r, fileInfo)
}
// lyricsHandler returns synced/unsynced lyrics (with or without timestamps) for an audio file (embedded or from .lrc files).
// @Summary Get lyrics for an audio file
// @Description Returns parsed lyrics with optional timestamps from embedded tags or sidecar .lrc files.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the directory or file"
// @Param source query string true "Source name"
// @Success 200 {object} map[string]interface{} "Lyrics array"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Not found"
// @Router /api/media/lyrics [get]
func lyricsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: false,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: false,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
}
if !strings.HasPrefix(fileInfo.Type, "audio") {
return http.StatusNotFound, fmt.Errorf("file is not an audio file")
}
lyrics, err := files.ExtractLyrics(fileInfo.RealPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract lyrics: %v", err)
}
return renderJSON(w, r, map[string]any{"lyrics": lyrics})
}
// publicLyricsHandler is the share-link variant of lyricsHandler.
// @Summary Get lyrics for an audio file (public share)
// @Tags Shares
// @Produce json
// @Param hash query string true "Share hash"
// @Param path query string false "Path within the share"
// @Success 200 {object} map[string]interface{} "Lyrics array"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Not found"
// @Router /public/api/media/lyrics [get]
func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceCfg, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: d.IndexPath,
Source: sourceCfg.Name,
Expand: true,
Content: false,
Metadata: false,
ShowHidden: d.share.ShowHidden,
HideFileExt: d.user.HideFileExt,
FollowSymlinks: false,
SkipExtendedAttrs: false,
}, store.Access, d.shareUser, store.Share)
if err != nil {
return errToStatus(err), err
}
if !strings.HasPrefix(fileInfo.Type, "audio") {
return http.StatusNotFound, fmt.Errorf("file is not an audio file")
}
lyrics, err := files.ExtractLyrics(fileInfo.RealPath)
if err != nil {
return http.StatusInternalServerError, errors.New("failed to extract lyrics")
}
return renderJSON(w, r, map[string]any{"lyrics": lyrics})
}
+54 -24
View File
@@ -8,7 +8,6 @@ import (
"net"
"net/http"
"net/url"
"path/filepath"
"runtime"
"slices"
"strings"
@@ -32,6 +31,7 @@ type requestContext struct {
fileInfo iteminfo.ExtendedFileInfo
token string
share *share.Link
shareValid bool
ctx context.Context
MaxBandwidth int
Data interface{}
@@ -53,12 +53,7 @@ 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")
inputPath := r.URL.Query().Get("path")
path, err := utils.SanitizeUserPath(inputPath)
if err != nil && inputPath != "" {
return http.StatusBadRequest, err
}
path = filepath.ToSlash(path)
path := r.URL.Query().Get("path")
// Get the file link by hash
link, err := store.Share.GetByHash(hash)
@@ -133,12 +128,9 @@ func withHashFileHelper(fn handleFunc) handleFunc {
Content: getContent,
Metadata: getMetadata,
AlbumArt: strings.Contains(r.URL.Path, "/preview"),
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ShowHidden: link.ShowHidden,
HideFileExt: link.HideFileExt,
FollowSymlinks: true,
ShowPinnedItems: true,
ShareHash: hash,
}, store.Access, data.shareUser, store.Share)
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
@@ -230,19 +222,57 @@ 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 != "" {
_, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share hash not found")
// 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
}
}
// Try to authenticate user first
status, err := withUserHelper(nil)(w, r, data)
if err == nil && status < 400 {
if data.share != nil && data.user != nil {
data.user.CustomTheme = data.share.ShareTheme
if data.share != nil && data.shareValid {
if data.user != nil {
data.user.CustomTheme = data.share.ShareTheme
}
}
return fn(w, r, data)
}
@@ -253,7 +283,7 @@ func withOrWithoutUserHelper(fn handleFunc) handleFunc {
userFromExpiredToken := extractUserFromExpiredToken(r, data)
if userFromExpiredToken != nil {
data.user = userFromExpiredToken
if data.share != nil {
if data.share != nil && data.shareValid {
data.user.CustomTheme = data.share.ShareTheme
}
setUserInResponseWriter(w, data.user)
@@ -265,7 +295,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 {
if data.share != nil && data.shareValid {
data.user.CustomTheme = data.share.ShareTheme
}
// Call the handler function without user context
@@ -629,8 +659,8 @@ func withoutUser(fn handleFunc) http.HandlerFunc {
return wrapHandler(withoutUserHelper(fn))
}
func loginHelper(fn handleFunc) handleFunc {
return LoginHelper(false, fn)
func loginHelper(fn handleFunc) http.HandlerFunc {
return wrapHandler(LoginHelper(false, fn))
}
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
@@ -725,7 +755,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 config.Http.TrustedHeaders["x-forwarded-for"] && xff != "" {
if xff != "" {
// The first IP is the original client
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
@@ -733,7 +763,7 @@ func getRemoteIP(r *http.Request) string {
// 2. Check X-Real-IP
xri := r.Header.Get("X-Real-IP")
if config.Http.TrustedHeaders["x-real-ip"] && xri != "" {
if xri != "" {
return xri
}
@@ -800,8 +830,8 @@ func LoggingMiddleware(next http.Handler) http.Handler {
}
duration := time.Since(start)
// ApiPathExclude is applied per logging sink inside go-logger (logger.ApiPath).
logger.ApiPath(wrappedWriter.StatusCode, fullURL,
// Use the StatusCode from wrappedWriter, which might have been set to 500 by the recover logic
logger.Api(wrappedWriter.StatusCode,
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode,
+4 -2
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 := config.Auth.Methods.OidcAuth
oidcCfg := settings.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 := config.Auth.Methods.OidcAuth
oidcCfg := settings.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,6 +255,8 @@ 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)
+25 -137
View File
@@ -11,13 +11,12 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -29,16 +28,8 @@ 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"`
@@ -90,7 +81,7 @@ type OnlyOfficeJWTPayload struct {
// @Router /api/office/config [get]
// @Security ApiKeyAuth
func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Integrations.OnlyOffice.Url == "" {
if settings.Config.Integrations.OnlyOffice.Url == "" {
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
}
@@ -116,13 +107,13 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
var sourceInfo *settings.Source
var ok bool
if hash != "" {
sourceInfo, ok = config.Server.SourceMap[d.share.Source]
sourceInfo, ok = settings.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 = config.Server.NameToSource[source]
sourceInfo, ok = settings.Config.Server.NameToSource[source]
if !ok {
logger.Error("OnlyOffice: source not found")
return http.StatusInternalServerError, fmt.Errorf("source not found")
@@ -219,7 +210,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
"title": d.fileInfo.Name,
"url": downloadURL,
"permissions": map[string]interface{}{
"edit": utils.Ternary(config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
"edit": utils.Ternary(settings.Config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
"download": true,
"print": true,
},
@@ -236,14 +227,14 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
"uiTheme": themeMode,
},
"lang": d.user.Locale,
"mode": utils.Ternary(config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
"mode": utils.Ternary(settings.Config.Integrations.OnlyOffice.ViewOnly, "view", canEditMode),
},
}
// Sign configuration with JWT if secret is configured
if config.Integrations.OnlyOffice.Secret != "" {
if settings.Config.Integrations.OnlyOffice.Secret != "" {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(clientConfig))
signature, err := token.SignedString([]byte(config.Integrations.OnlyOffice.Secret))
signature, err := token.SignedString([]byte(settings.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")
@@ -258,10 +249,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 config.Server.InternalUrl != "" {
if settings.Config.Server.InternalUrl != "" {
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
internalURL := strings.TrimSuffix(config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(config.Server.BaseURL, "/")
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
if baseURLPath != "" {
baseURL = internalURL + "/" + baseURLPath
} else {
@@ -285,7 +276,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, config.Server.BaseURL)
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
}
escapedPath := url.QueryEscape(path)
@@ -302,10 +293,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 config.Server.InternalUrl != "" {
if settings.Config.Server.InternalUrl != "" {
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
internalURL := strings.TrimSuffix(config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(config.Server.BaseURL, "/")
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
if baseURLPath != "" {
baseURL = internalURL + "/" + baseURLPath
} else {
@@ -329,7 +320,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, config.Server.BaseURL)
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
}
var callbackURL string
@@ -356,97 +347,6 @@ 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
@@ -482,7 +382,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
//
// When the document is fully closed by all editors,
// the document key should no longer be re-used.
deleteOfficeId(source, path, user)
deleteOfficeId(source, path)
// Send log event for document closure and clean up log context
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
@@ -592,12 +492,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
}
// Download the updated document from OnlyOffice server
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)
doc, err := http.Get(data.URL)
if err != nil {
logger.Errorf("OnlyOffice callback: failed to download updated document: %v", err)
return returnOnlyOfficeError(w, r, 500, "failed to download updated document")
@@ -755,21 +650,14 @@ func getOnlyOfficeId(realpath string) (string, error) {
return "", fmt.Errorf("document key not found")
}
func deleteOfficeId(source, path string, user *users.User) {
fi, err := files.FileInfoFaster(utils.FileOptions{
Path: path,
Source: source,
Expand: false,
FollowSymlinks: true,
}, store.Access, user, store.Share)
key := path
if fi != nil && fi.RealPath != "" {
key = fi.RealPath
func deleteOfficeId(source, path string) {
idx := indexing.GetIndex(source)
if idx == nil {
logger.Errorf("deleteOfficeId: failed to find source index for user home dir creation: %s", source)
return
}
if err != nil {
logger.Errorf("deleteOfficeId: failed to resolve realpath, source=%s, path=%s: %v", source, path, err)
}
utils.OnlyOfficeCache.Delete(key)
realpath, _, _ := idx.GetRealPath(path)
utils.OnlyOfficeCache.Delete(realpath)
}
// parseOnlyOfficeJWT parses the JWT token from OnlyOffice callback
-182
View File
@@ -1,182 +0,0 @@
package http
import (
"fmt"
"net/url"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func TestResolveOnlyOfficeDownloadURL(t *testing.T) {
orig := config.Integrations.OnlyOffice
t.Cleanup(func() {
config.Integrations.OnlyOffice = orig
})
config.Integrations.OnlyOffice.Url = "http://192.168.88.100:8282"
config.Integrations.OnlyOffice.InternalUrl = "http://onlyoffice"
cachePath := "/cache/files/data/doc_1/output.ods/output.ods?md5=abc&expires=1"
publicURL := "http://192.168.88.100:8282" + cachePath
internalURL := "http://onlyoffice" + cachePath
tests := []struct {
name string
input string
want string
}{
{
name: "rewrites public cache URL to internal host",
input: publicURL,
want: internalURL,
},
{
name: "empty URL",
input: "",
want: "",
},
{
name: "untrusted host rejected",
input: "http://other-host:8282" + cachePath,
want: "",
},
{
name: "non-http scheme rejected",
input: "ftp://192.168.88.100:8282" + cachePath,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveOnlyOfficeDownloadURL(tt.input)
if got != tt.want {
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want %q", got, tt.want)
}
})
}
t.Run("pass through public URL when internalUrl unset", func(t *testing.T) {
config.Integrations.OnlyOffice.InternalUrl = ""
got := resolveOnlyOfficeDownloadURL(publicURL)
if got != publicURL {
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want %q", got, publicURL)
}
})
t.Run("hostname match with default http port", func(t *testing.T) {
config.Integrations.OnlyOffice.Url = "http://office.local"
config.Integrations.OnlyOffice.InternalUrl = "http://onlyoffice"
input := "http://office.local:80" + cachePath
want := "http://onlyoffice" + cachePath
got := resolveOnlyOfficeDownloadURL(input)
if got != want {
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want %q", got, want)
}
})
t.Run("reject when public url not configured", func(t *testing.T) {
config.Integrations.OnlyOffice.Url = ""
config.Integrations.OnlyOffice.InternalUrl = "http://onlyoffice"
got := resolveOnlyOfficeDownloadURL(publicURL)
if got != "" {
t.Errorf("resolveOnlyOfficeDownloadURL() = %q, want empty", got)
}
})
}
func TestDeleteOfficeId(t *testing.T) {
const rawPath = "/docs/document.docx"
tests := []struct {
name string
resolve func(utils.FileOptions) (*iteminfo.ExtendedFileInfo, error)
deleteKey string
remainKey string
}{
{
name: "deletes resolved realpath",
resolve: func(utils.FileOptions) (*iteminfo.ExtendedFileInfo, error) {
return &iteminfo.ExtendedFileInfo{RealPath: "/some/path/document.docx"}, nil
},
deleteKey: "/some/path/document.docx",
remainKey: rawPath,
},
{
name: "fallback to raw path on error",
resolve: func(utils.FileOptions) (*iteminfo.ExtendedFileInfo, error) {
return nil, fmt.Errorf("could not resolve path")
},
deleteKey: rawPath,
remainKey: "/some/path/document.docx",
},
{
name: "also deletes realpath when partially resolved before erroring",
resolve: func(utils.FileOptions) (*iteminfo.ExtendedFileInfo, error) {
return &iteminfo.ExtendedFileInfo{RealPath: "/some/path/document.docx"}, fmt.Errorf("access check failed")
},
deleteKey: "/some/path/document.docx",
remainKey: rawPath,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
origStore := store
store = &bolt.BoltStore{Access: &access.Storage{}, Share: &share.Storage{}}
t.Cleanup(func() { store = origStore })
origFunc := files.FileInfoFasterFunc
t.Cleanup(func() { files.FileInfoFasterFunc = origFunc })
files.FileInfoFasterFunc = func(opts utils.FileOptions, accessStore *access.Storage, user *users.User, shareStore *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
if opts.Path != rawPath {
t.Errorf("expected opts.Path=%q, got %q", rawPath, opts.Path)
}
if opts.Source != "source" {
t.Errorf("expected opts.Source=%q, got %q", "source", opts.Source)
}
if !opts.FollowSymlinks {
t.Error("expected FollowSymlinks=true")
}
return tt.resolve(opts)
}
utils.OnlyOfficeCache.Set(tt.deleteKey, "document-key")
utils.OnlyOfficeCache.Set(tt.remainKey, "other-key")
t.Cleanup(func() {
utils.OnlyOfficeCache.Delete(tt.deleteKey)
utils.OnlyOfficeCache.Delete(tt.remainKey)
})
deleteOfficeId("source", rawPath, &users.User{})
if _, err := getOnlyOfficeId(tt.deleteKey); err == nil {
t.Errorf("expected cache entry %q to be deleted", tt.deleteKey)
}
if _, err := getOnlyOfficeId(tt.remainKey); err != nil {
t.Errorf("expected cache entry %q to remain, but it was deleted", tt.remainKey)
}
})
}
}
func TestOnlyOfficeURLHostsMatch(t *testing.T) {
mustParse := func(raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("url.Parse(%q): %v", raw, err)
}
return u
}
if !onlyOfficeURLHostsMatch(mustParse("http://office.local:80/x"), mustParse("http://office.local/x")) {
t.Fatal("expected office.local:80 to match office.local with default port")
}
if onlyOfficeURLHostsMatch(mustParse("http://evil.example/x"), mustParse("http://office.local/x")) {
t.Fatal("expected different hostnames not to match")
}
}
-145
View File
@@ -1,145 +0,0 @@
package http
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// pinnedItemPatchRequest is the JSON body for PATCH /api/users/pinnedItems.
type pinnedItemPatchRequest struct {
Source string `json:"source" validate:"required"`
Path string `json:"path" validate:"required"` // scope-relative parent directory
Name string `json:"name" validate:"required"` // item basename within path
}
// sharePinnedItemPatchRequest is the JSON body for PATCH /public/api/share/pinnedItems.
type sharePinnedItemPatchRequest struct {
Path string `json:"path" validate:"required"` // share-relative parent directory
Name string `json:"name" validate:"required"` // item basename within path
}
// scopeRelativeDirToIndexPath maps a scope-relative directory to source and index paths.
func scopeRelativeDirToIndexPath(sourceName, userScope, directoryPath string) (sourcePath, indexDirPath string, err error) {
userScope = strings.TrimRight(userScope, "/")
source, ok := users.ResolveSourceKey(sourceName)
if !ok {
return "", "", fmt.Errorf("source not found: %s", sourceName)
}
idx := indexing.GetIndex(source.Name)
if idx == nil {
return "", "", fmt.Errorf("index not found for source %s", source.Name)
}
cleanDir, err := utils.SanitizeUserPath(directoryPath)
if err != nil {
return "", "", fmt.Errorf("invalid path: %w", err)
}
fullDirPath := utils.JoinPathAsUnix(userScope, cleanDir)
return source.Path, idx.MakeIndexPath(fullDirPath, true), nil
}
// normalizeShareRelativeDir returns a normalized share-relative directory path key.
func normalizeShareRelativeDir(shareRelDir string) (string, error) {
if shareRelDir == "" || shareRelDir == "/" {
return "/", nil
}
cleanDir, err := utils.SanitizeUserPath(shareRelDir)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
return utils.AddTrailingSlashIfNotExists(cleanDir), nil
}
// pinnedItemAction returns the patch action query param, defaulting to "add".
func pinnedItemAction(r *http.Request) string {
action := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("action")))
if action == "" {
return "add"
}
return action
}
// userPatchPinnedItemsHandler adds or removes a single pinned item for the authenticated user.
// @Summary Add or remove a pinned item
// @Description Patches one pinned item at a time. Defaults to add; pass ?action=remove to unpin.
// @Tags Users
// @Accept json
// @Produce json
// @Param action query string false "add (default) or remove"
// @Param body body pinnedItemPatchRequest true "Pinned item"
// @Success 204 "No Content"
// @Failure 400 {object} map[string]string "Bad Request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users/pinnedItems [patch]
func userPatchPinnedItemsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
var body pinnedItemPatchRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
}
defer r.Body.Close()
action := pinnedItemAction(r)
if action != "add" && action != "remove" {
return http.StatusBadRequest, fmt.Errorf("action must be add or remove")
}
if body.Source == "" || body.Path == "" || body.Name == "" {
return http.StatusBadRequest, fmt.Errorf("source, path, and name are required")
}
cleanPath, err := utils.SanitizeUserPath(body.Path)
if err != nil {
return http.StatusBadRequest, err
}
body.Path = cleanPath
cleanName, err := utils.SanitizeUserPath(body.Name)
if err != nil {
return http.StatusBadRequest, err
}
body.Name = cleanName
source, ok := users.ResolveSourceKey(body.Source)
if !ok {
return http.StatusBadRequest, fmt.Errorf("source not found: %s", body.Source)
}
if !d.user.HasSourceByPath(source.Path) {
return http.StatusForbidden, fmt.Errorf("access denied to source %s", body.Source)
}
userScope, err := d.user.GetScopeForSourcePath(source.Path)
if err != nil {
return http.StatusForbidden, err
}
sourcePath, indexDirPath, err := scopeRelativeDirToIndexPath(body.Source, userScope, body.Path)
if err != nil {
return http.StatusBadRequest, err
}
u, err := store.Users.Get(d.user.ID)
if err != nil {
return http.StatusInternalServerError, err
}
pinned := u.EnsurePinnedItems()
switch action {
case "add":
pinned.Add(sourcePath, indexDirPath, body.Name)
case "remove":
pinned.Remove(sourcePath, indexDirPath, body.Name)
}
if err := store.Users.Update(u, d.user.Permissions.Admin, "PinnedItems"); err != nil {
return http.StatusInternalServerError, err
}
return http.StatusNoContent, nil
}
+15 -35
View File
@@ -14,6 +14,7 @@ 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"
@@ -84,22 +85,6 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
return status, nil
}
func shouldServeOriginalPreview(ext string, resizable bool, realPath, previewSize string, fileSize int64) bool {
if !resizable {
return false
}
// HEIC/TIFF still need conversion for reliable web display.
switch ext {
case ".heic", ".heif", ".tiff", ".tif":
return false
}
const maxSizeForOriginal = 256 * 1024 // 256KB
if config.Server.DisableResize || fileSize < maxSizeForOriginal {
return true
}
return preview.ShouldServeOriginalImage(realPath, previewSize)
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
fd, err := os.Open(file.RealPath)
if err != nil {
@@ -120,7 +105,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 !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
continue
}
previewableNames = append(previewableNames, item.Name)
@@ -136,7 +121,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 := config.Server.SourceMap[d.share.Source]
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
return nil, fmt.Errorf("source not found for share")
}
@@ -171,11 +156,6 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
}
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
ctx := r.Context()
if d.ctx != nil {
ctx = d.ctx
}
previewSize := r.URL.Query().Get("size")
if !(previewSize == "large" || previewSize == "original" || previewSize == "xlarge") {
previewSize = "small"
@@ -212,12 +192,6 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
}
fileInfo, err := getDirectoryPreview(r, d, dirFrameIndex)
if err != nil {
if isClientCancellation(ctx, err) {
return http.StatusOK, nil
}
if ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) {
return http.StatusRequestTimeout, fmt.Errorf("preview generation timed out")
}
logger.Errorf("error getting directory preview: %v", err)
return http.StatusInternalServerError, err
}
@@ -230,9 +204,9 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
resizable := iteminfo.ResizableImageTypes[ext]
// For small displayable images (jpg, png, etc.) serve the original to avoid processing.
// Also serve the original when dimensions already fit the requested preview size.
if isImage && shouldServeOriginalPreview(ext, resizable, d.fileInfo.RealPath, previewSize, d.fileInfo.Size) {
// For small, displayable images (jpg, png, etc.) serve the original to avoid processing.
const maxSizeForOriginal = 128 * 1024 // 128KB
if resizable && (config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
return rawFileHandler(w, r, d.fileInfo)
}
@@ -240,7 +214,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 config.Server.InternalUrl != "" {
if settings.Config.Server.InternalUrl != "" {
officeUrl = config.Server.InternalUrl + pathUrl
} else {
scheme := "http"
@@ -250,6 +224,12 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
officeUrl = scheme + "://" + r.Host + pathUrl
}
}
// Use the context from the request context (which includes timeout)
ctx := r.Context()
if d.ctx != nil {
ctx = d.ctx
}
previewImg, err := preview.GetPreviewForFile(ctx, d.fileInfo, previewSize, officeUrl, seekPercentage)
if err != nil {
// Check if it was a context cancellation (client navigated away)
@@ -260,8 +240,8 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
// Check if it was a context timeout (server-side timeout)
if ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) {
logger.Errorf("Preview timeout for file '%s' after 15 seconds", d.fileInfo.Name)
return http.StatusRequestTimeout, fmt.Errorf("preview generation timed out after 15 seconds")
logger.Errorf("Preview timeout for file '%s' after 60 seconds", d.fileInfo.Name)
return http.StatusRequestTimeout, fmt.Errorf("preview generation timed out after 60 seconds")
}
// Log detailed error information for actual server errors
+4 -12
View File
@@ -8,6 +8,7 @@ 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"
@@ -79,7 +80,7 @@ func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestCon
}
// Get the actual source name from the share's source mapping
sourceInfo, ok := config.Server.SourceMap[d.share.Source]
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusInternalServerError, fmt.Errorf("source not found for share")
}
@@ -367,19 +368,10 @@ 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, 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].FromPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].FromPath)
req.Items[i].ToSource = sourceName
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, sanitizedPath)
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].ToPath)
}
d.Data = req
+26 -113
View File
@@ -18,6 +18,7 @@ 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"
@@ -98,12 +99,10 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
Expand: true,
Content: getContent,
Metadata: getMetadata,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: skipExtendedAttrs,
ShowSharedAttr: true,
ShowPinnedItems: true,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
@@ -146,28 +145,18 @@ 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,
HideFileExt: d.user.HideFileExt,
Path: path,
Source: source,
Expand: false,
ShowHidden: d.user.ShowHidden,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
@@ -262,78 +251,43 @@ 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 rawPath == "" {
if item.Path == "" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: item.Path,
Message: "path was empty",
})
continue
}
// Prevent deletion of root
if rawPath == "/" {
if item.Path == "/" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: item.Path,
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 == "" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Message: "source name is empty",
})
continue
return http.StatusNotFound, fmt.Errorf("source not available")
}
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: sanitizedPath,
Path: item.Path,
Message: "user does not have access",
})
continue
}
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
indexPath := utils.JoinPathAsUnix(withoutUserScope, sanitizedPath)
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
@@ -351,7 +305,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: sanitizedPath,
Path: item.Path,
Message: "error deleting file/directory, admin must check the logs",
})
continue
@@ -363,7 +317,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
if item.Source == "" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: item.Path,
Message: "source was empty, source is required",
})
continue
@@ -374,7 +328,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: item.Path,
Message: fmt.Sprintf("user does not have access: %v", err),
})
continue
@@ -384,32 +338,23 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
if idx == nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: item.Path,
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: sanitizedPath,
Path: item.Path,
Source: item.Source,
ShowHidden: true,
}, store.Access, filePermUser, store.Share)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: item.Path,
Message: err.Error(),
})
continue
@@ -418,7 +363,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: item.Path,
Message: err.Error(),
})
continue
@@ -536,17 +481,6 @@ 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
@@ -558,6 +492,11 @@ 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 {
@@ -771,9 +710,6 @@ 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)
@@ -907,28 +843,6 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
continue
}
if srcIdx.Config.ReadOnly && req.Action == "move" {
item.Message = "From source is read-only and cannot be moved"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
}
if dstIdx.Config.ReadOnly {
item.Message = "destination source is read-only"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
})
continue
}
response.Failed = append(response.Failed, item)
continue
}
// Build full index paths for access control
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
@@ -1125,7 +1039,6 @@ 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 {
+58 -207
View File
@@ -8,31 +8,22 @@ 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 {
parsed iteminfo.SearchOptions
query string
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
@@ -40,43 +31,28 @@ type scopedSourcePath 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.
//
// 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 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.
//
// 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: 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).
// - query: The search terms to use (required)
// - source: Source name (deprecated, use 'sources' instead)
// - 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
// - sources: Comma-separated list of source names (e.g., "source1,source2")
// - scope: Optional path within user scope to 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 with per-source folders):
// Example request (multiple sources):
//
// GET api/tools/search?query=myfile&scope=source1:/&scope=source2:/documents
// GET api/tools/search?query=myfile&sources=source1,source2
//
// Example response:
// [
@@ -99,17 +75,13 @@ type scopedSourcePath struct {
// @Tags Tools
// @Accept json
// @Produce json
// @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 query query string true "Search query"
// @Param source query string false "Source name for the desired source (deprecated, use 'sources' instead)"
// @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 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 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 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"
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
// @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]
@@ -132,10 +104,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.SearchParsed(searchOptions.parsed, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix, searchOptions.useWildcard)
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
} else {
// Multiple sources - use the new SearchMultiSources function
response = indexing.SearchMultiSourcesParsed(searchOptions.parsed, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix, searchOptions.useWildcard)
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
}
// Filter out items that are not permitted according to access rules and trim user scope from paths
@@ -153,85 +125,16 @@ 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
scopeValues := r.URL.Query()["scope"] // repeated; use slice, not Get (first only)
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
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
@@ -240,116 +143,64 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
if err != nil {
return nil, err
}
scopedClauses, legacyScopeOnly, err := parseRepeatedScopeParams(scopeValues)
if scope == "" {
scope = "/"
}
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, err
return nil, fmt.Errorf("invalid scope: %v", err)
}
normalizedTerms := make([]string, 0, len(rawTerms))
for _, t := range rawTerms {
t = strings.TrimSpace(t)
if t != "" {
normalizedTerms = append(normalizedTerms, t)
}
}
parsed := iteminfo.BuildSearchOptionsFromQuery(query, normalizedTerms, matchAllTerms)
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)
}
}
sessionId := r.Header.Get("SessionId")
scope = cleanScope
var sources []string
combinedPathMap := make(map[string]string)
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)
}
if sourcesParam != "" {
sources = strings.Split(sourcesParam, ",")
} else if sourceParam != "" {
sources = []string{sourceParam}
} 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])
}
return nil, fmt.Errorf("either 'source' or 'sources' query parameter is required")
}
for _, source := range sources {
index := indexing.GetIndex(source)
if index == nil {
return nil, fmt.Errorf("index not found for source %s", source)
}
// 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)
}
}
scope := legacyScopeOnly
if scope == "" {
scope = "/"
}
cleanScope, err := utils.SanitizeUserPath(scope)
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, ".")
// If multiple sources, always use user scope (ignore searchScope)
if len(sources) > 1 {
searchScope = ""
}
// Retrieve the User-Agent and X-Auth headers from the request
sessionId := r.Header.Get("SessionId")
// Build combinedPath map for each source
combinedPathMap := make(map[string]string)
for _, source := range sources {
index := indexing.GetIndex(source)
userscope, err := d.user.GetScopeForSourceName(source)
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
return nil, err
}
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope), true) // searchScope is a directory
combinedPathMap[source] = combinedPath
}
return &searchOptions{
parsed: parsed,
query: query,
sources: sources,
searchScope: searchScopeOut,
searchScope: searchScope,
combinedPath: combinedPathMap,
sessionId: sessionId,
largest: largest,
useWildcard: useWildcard,
olderThanUnix: olderThanUnix,
newerThanUnix: newerThanUnix,
}, nil
+1 -82
View File
@@ -206,12 +206,6 @@ 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 {
@@ -221,7 +215,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, sanitizedPath)
err = store.Share.UpdateSharePath(body.Hash, body.Path)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -700,78 +694,3 @@ func redirectToShare(w http.ResponseWriter, r *http.Request, d *requestContext)
http.Redirect(w, r, newURL, http.StatusMovedPermanently)
return http.StatusMovedPermanently, nil
}
// publicSharePatchPinnedItemsHandler adds or removes a pinned item on a share link.
// @Summary Add or remove a pinned item on a share
// @Description Patches one pinned item at a time for share owners. Defaults to add; pass ?action=remove to unpin.
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash"
// @Param action query string false "add (default) or remove"
// @Param body body sharePinnedItemPatchRequest true "Pinned item"
// @Success 204 "No Content"
// @Failure 400 {object} map[string]string "Bad Request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Share not found"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /public/api/share/pinnedItems [patch]
func sharePatchPinnedItemsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
if hash == "" {
return http.StatusBadRequest, fmt.Errorf("hash is required")
}
link, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusNotFound, fmt.Errorf("share hash not found")
}
if d.user.Username == "anonymous" || !link.UserCanEdit(d.user) {
return http.StatusForbidden, fmt.Errorf("share pin editing is not allowed for this user")
}
if link.ShareType == "upload" {
return http.StatusForbidden, fmt.Errorf("pinning is disabled for upload shares")
}
var body sharePinnedItemPatchRequest
if err = json.NewDecoder(r.Body).Decode(&body); err != nil {
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
}
defer r.Body.Close()
action := pinnedItemAction(r)
if action != "add" && action != "remove" {
return http.StatusBadRequest, fmt.Errorf("action must be add or remove")
}
if body.Path == "" || body.Name == "" {
return http.StatusBadRequest, fmt.Errorf("path and name are required")
}
shareRelDir, err := normalizeShareRelativeDir(body.Path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %s", body.Path)
}
cleanName, err := utils.SanitizeUserPath(body.Name)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid name: %s", body.Name)
}
body.Name = cleanName
pinned := link.EnsurePinnedItems()
switch action {
case "add":
pinned.Add(shareRelDir, body.Name)
case "remove":
pinned.Remove(shareRelDir, body.Name)
}
if err := store.Share.Save(link); err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to save share: %w", err)
}
return http.StatusNoContent, nil
}
+38 -74
View File
@@ -6,7 +6,6 @@ import (
"html/template"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -96,19 +95,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
favicon := staticURL + "/favicon" + faviconExt
shareHash := ""
data := make(map[string]interface{})
disableNavButtons := config.Frontend.DisableNavButtons
if d.share != nil {
shareHash = d.share.Hash
}
if shareHash == "" {
shareHash = icons.ShareHashFromHTTPPath(r.URL.Path, config.Server.BaseURL)
}
if shareHash != "" && d.share == nil && store != nil {
if link, err := store.Share.GetByHash(shareHash); err == nil {
d.share = link
}
}
if d.share != nil {
disableNavButtons := settings.Config.Frontend.DisableNavButtons
if d.share != nil && d.shareValid {
if d.share.Favicon != "" {
if strings.HasPrefix(d.share.Favicon, "http") {
favicon = d.share.Favicon
@@ -136,6 +124,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
if shareFavicon != "" {
favicon = shareFavicon
}
shareHash = d.share.Hash
}
// Set login icon URL
loginIcon := staticURL + "/loginIcon"
@@ -173,20 +162,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
manifestURL := staticURL + "/site.webmanifest"
if shareHash != "" {
pwaStartURL := config.Server.BaseURL + "public/share/" + shareHash + "/"
query := url.Values{}
query.Set("start", pwaStartURL)
if title != config.Frontend.Name {
query.Set("name", title)
}
if description != config.Frontend.Description {
query.Set("description", description)
}
manifestURL = staticURL + "/site.webmanifest?" + query.Encode()
}
data["htmlVars"] = map[string]interface{}{
"title": title,
"customCSS": template.CSS(config.Frontend.Styling.CustomCSSRaw),
@@ -208,43 +183,41 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"pwaIcon192": pwaIcon192,
"pwaIcon256": pwaIcon256,
"pwaIcon512": pwaIcon512,
"manifestURL": manifestURL,
"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": 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,
"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,
}
// Marshal each variable to JSON strings for direct template usage
@@ -292,21 +265,12 @@ func setContentType(w http.ResponseWriter, path string) {
}
}
// manifestHandler serves the cached PWA manifest, optionally scoped to a public share.
// manifestHandler serves the cached PWA manifest
func manifestHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
manifest := icons.CachedManifest
if startURL := r.URL.Query().Get("start"); startURL != "" {
name := r.URL.Query().Get("name")
description := r.URL.Query().Get("description")
if shareManifest, ok := icons.ManifestForShare(config.Server.BaseURL, startURL, name, description); ok {
manifest = shareManifest
}
}
if err := json.NewEncoder(w).Encode(manifest); err != nil {
if err := json.NewEncoder(w).Encode(icons.CachedManifest); err != nil {
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
}
}
-104
View File
@@ -1,104 +0,0 @@
package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/pquerna/otp/totp"
)
func otpRequest(username, password, code string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/auth/otp?username="+username, http.NoBody)
req.Header.Set("X-Password", password)
if code != "" {
req.Header.Set("X-Secret", code)
}
return req
}
func TestVerifyOTP_CachedSecretTakesPrecedence(t *testing.T) {
setupTestEnv(t)
oldSecret := "SOMEOLDSECRET234"
user := &users.User{
ID: 1,
Username: "test",
NonAdminEditable: users.NonAdminEditable{Password: "testPass"},
TOTPSecret: oldSecret,
OtpEnabled: true,
}
if err := store.Users.Save(user, true, true); err != nil {
t.Fatalf("failed to save user: %v", err)
}
t.Cleanup(func() { auth.TotpCache.Delete(user.Username) })
d := &requestContext{user: user}
rec := httptest.NewRecorder()
if _, err := generateOTPHandler(rec, otpRequest(user.Username, "testPass", ""), d); err != nil {
t.Fatalf("genration failed: %v", err)
}
var resp map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
u, err := url.Parse(resp["url"])
if err != nil {
t.Fatalf("failed to parse url: %v", err)
}
newSecret := u.Query().Get("secret")
if newSecret == oldSecret {
t.Fatal("expected a fresh secret")
}
oldCode, err := totp.GenerateCode(oldSecret, time.Now())
if err != nil {
t.Fatalf("failed to generate old code: %v", err)
}
if _, verifyErr := verifyOTPHandler(httptest.NewRecorder(), otpRequest(user.Username, "testPass", oldCode), d); verifyErr == nil {
t.Error("expected old secret to be rejected")
}
newCode, err := totp.GenerateCode(newSecret, time.Now())
if err != nil {
t.Fatalf("failed to generate new code: %v", err)
}
if _, verifyErr := verifyOTPHandler(httptest.NewRecorder(), otpRequest(user.Username, "testPass", newCode), d); verifyErr != nil {
t.Fatalf("expected new secret to be accepted: %v", verifyErr)
}
updated, err := store.Users.Get(user.Username)
if err != nil {
t.Fatalf("failed to reload user: %v", err)
}
if updated.TOTPSecret != newSecret {
t.Error("expected TOTPSecret to be updated to the new secret")
}
if _, found := auth.TotpCache.Get(user.Username); found {
t.Error("expected cache to be cleared after verification")
}
}
func TestLogin_EnforcedOtp(t *testing.T) {
setupTestEnv(t)
config.Auth.Key = "test-key"
config.Auth.TokenExpirationHours = 1
config.Auth.Methods.PasswordAuth.EnforcedOtp = true
user := &users.User{ID: 2, Username: "loginuser", LoginMethod: users.LoginMethodPassword}
d := &requestContext{user: user}
status, err := loginHandler(httptest.NewRecorder(), otpRequest(user.Username, "", ""), d)
if status != http.StatusForbidden || err != errors.ErrNoTotpConfigured {
t.Fatalf("expected login without TOTP to fail, got=%d err=%v", status, err)
}
user.TOTPSecret = "SOMESECRET123456"
if _, loginErr := loginHandler(httptest.NewRecorder(), otpRequest(user.Username, "", ""), d); loginErr != nil {
t.Fatalf("expected login with TOTP to succed, got err=%v", loginErr)
}
}
+1 -53
View File
@@ -90,7 +90,6 @@ 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
@@ -99,59 +98,8 @@ func prepForFrontend(u *users.User) {
u.TOTPSecret = ""
u.TOTPNonce = ""
u.Scopes = u.GetFrontendScopes()
u.SidebarLinks = GetFrontendSidebarLinks(u.SidebarLinks, u.ShowToolsInSidebar)
u.PinnedItems = nil
u.SidebarLinks = u.GetFrontendSidebarLinks()
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)
-240
View File
@@ -1,240 +0,0 @@
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,7 +84,6 @@ 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)
@@ -383,7 +382,6 @@ 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
config.Server.CacheDir = tempDir
// Setup config with sources - update both the local config var AND the global config
settings.Config.Server.CacheDir = tempDir
// Setup config with sources - update both the local config var AND the global settings.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 config so access.Permitted can find sources
// CRITICAL: Also update the global settings.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)
-2
View File
@@ -2,7 +2,6 @@ package icons
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
@@ -206,7 +205,6 @@ func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon
// Resize image using the preview service's Resize method
err = previewService.Resize(
context.Background(),
bytes.NewReader(sourceData),
outFile,
preview.ResizeOptions{
-4
View File
@@ -8,8 +8,6 @@ import (
type PWAManifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
ID string `json:"id"`
Scope string `json:"scope"`
Icons []PWAIcon `json:"icons"`
StartURL string `json:"start_url"`
Display string `json:"display"`
@@ -42,8 +40,6 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
return PWAManifest{
Name: name,
ShortName: shortName,
ID: baseURL,
Scope: baseURL,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
-108
View File
@@ -1,108 +0,0 @@
package icons
import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
)
const maxManifestTextLen = 256
func isValidShareHash(hash string) bool {
if len(hash) < 8 || len(hash) > 64 {
return false
}
if strings.Contains(hash, "..") || strings.Contains(hash, "/") {
return false
}
return true
}
// ShareRootFromStartURL validates a share PWA start URL and returns the share root
// used for manifest scope/id plus the normalized start URL.
func ShareRootFromStartURL(baseURL, startURL string) (shareRoot, normalizedStart string, ok bool) {
if startURL == "" || !strings.HasPrefix(startURL, baseURL) {
return "", "", false
}
rel := strings.TrimPrefix(startURL, baseURL)
if !strings.HasPrefix(rel, "public/share/") {
return "", "", false
}
rest := strings.TrimPrefix(rel, "public/share/")
hash, subPath, _ := strings.Cut(rest, "/")
if !isValidShareHash(hash) {
return "", "", false
}
if subPath != "" {
if _, err := utils.SanitizeUserPath(subPath); err != nil {
return "", "", false
}
}
shareRoot = baseURL + "public/share/" + hash + "/"
normalizedStart = startURL
if !strings.HasPrefix(normalizedStart, shareRoot) && normalizedStart != strings.TrimSuffix(shareRoot, "/") {
return "", "", false
}
if normalizedStart == baseURL+"public/share/"+hash {
normalizedStart = shareRoot
}
return shareRoot, normalizedStart, true
}
// ShareHashFromHTTPPath extracts a share hash from common FileBrowser share routes.
func ShareHashFromHTTPPath(path, baseURL string) string {
prefixes := []string{
strings.TrimSuffix(baseURL, "/") + "/public/share/",
"/public/share/",
"/share/",
}
for _, prefix := range prefixes {
if !strings.HasPrefix(path, prefix) {
continue
}
rest := strings.TrimPrefix(path, prefix)
hash, _, _ := strings.Cut(rest, "/")
if isValidShareHash(hash) {
return hash
}
}
return ""
}
func sanitizeManifestText(value string) string {
if len(value) > maxManifestTextLen {
return value[:maxManifestTextLen]
}
return value
}
// ManifestForShare returns a manifest scoped to a single public share link.
func ManifestForShare(baseURL, startURL, name, description string) (PWAManifest, bool) {
shareRoot, normalizedStart, ok := ShareRootFromStartURL(baseURL, startURL)
if !ok {
return PWAManifest{}, false
}
manifest := CachedManifest
manifest.StartURL = normalizedStart
manifest.Scope = shareRoot
manifest.ID = shareRoot
if name = sanitizeManifestText(strings.TrimSpace(name)); name != "" {
manifest.Name = name
shortName := name
if len(shortName) > 12 {
shortName = shortName[:12]
}
manifest.ShortName = shortName
}
if description = sanitizeManifestText(strings.TrimSpace(description)); description != "" {
manifest.Description = description
}
return manifest, true
}
-80
View File
@@ -1,80 +0,0 @@
package icons
import "testing"
func TestShareRootFromStartURL(t *testing.T) {
baseURL := "/testing/"
startURL := "/testing/public/share/abc123XYZ-_/"
shareRoot, normalizedStart, ok := ShareRootFromStartURL(baseURL, startURL)
if !ok {
t.Fatal("expected valid share start URL")
}
if shareRoot != "/testing/public/share/abc123XYZ-_/" {
t.Fatalf("unexpected share root: %q", shareRoot)
}
if normalizedStart != startURL {
t.Fatalf("unexpected normalized start: %q", normalizedStart)
}
}
func TestShareRootFromStartURLRejectsTraversal(t *testing.T) {
baseURL := "/testing/"
startURL := "/testing/public/share/abc123XYZ-_/../secret/"
_, _, ok := ShareRootFromStartURL(baseURL, startURL)
if ok {
t.Fatal("expected traversal path to be rejected")
}
}
func TestShareHashFromHTTPPath(t *testing.T) {
baseURL := "/testing/"
paths := []string{
"/testing/public/share/hash1234567890abcdef/",
"/public/share/hash1234567890abcdef/files",
"/share/hash1234567890abcdef/",
}
for _, path := range paths {
hash := ShareHashFromHTTPPath(path, baseURL)
if hash != "hash1234567890abcdef" {
t.Fatalf("path %q: got hash %q", path, hash)
}
}
}
func TestShareHashFromHTTPPathRejectsEmbeddedPrefix(t *testing.T) {
baseURL := "/testing/"
path := "/evil/route/share/hash1234567890abcdef/"
hash := ShareHashFromHTTPPath(path, baseURL)
if hash != "" {
t.Fatalf("expected empty hash for non-prefixed path, got %q", hash)
}
}
func TestManifestForShare(t *testing.T) {
CachedManifest = PWAManifest{
Name: "FileBrowser Quantum",
ShortName: "FBQ",
StartURL: "/testing/",
Scope: "/testing/",
ID: "/testing/",
}
manifest, ok := ManifestForShare(
"/testing/",
"/testing/public/share/hash1234567890abcdef/",
"My Share",
"Shared files",
)
if !ok {
t.Fatal("expected share manifest")
}
if manifest.StartURL != "/testing/public/share/hash1234567890abcdef/" {
t.Fatalf("unexpected start_url: %q", manifest.StartURL)
}
if manifest.Scope != "/testing/public/share/hash1234567890abcdef/" {
t.Fatalf("unexpected scope: %q", manifest.Scope)
}
if manifest.Name != "My Share" {
t.Fatalf("unexpected name: %q", manifest.Name)
}
}
+2 -1
View File
@@ -14,7 +14,8 @@ func setupConditionalTestIndex(conditionals settings.ConditionalFilter) *Index {
Name: "test",
Path: "/test/path",
Config: settings.SourceConfig{
Conditionals: conditionals,
DisableIndexing: false,
Conditionals: conditionals,
},
}
+5 -3
View File
@@ -26,9 +26,11 @@ 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{},
Name: "test_delete",
Path: "/mock/path",
Config: settings.SourceConfig{
DisableIndexing: false,
},
},
db: indexDB,
mock: true, // Enable mock mode
+25 -29
View File
@@ -16,26 +16,44 @@ 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/",
opts: Options{SkipExtendedAttrs: true, Recursive: false},
name: "SkipExtendedAttrs returns empty",
adjustedPath: "/test/",
combinedPath: "/test/",
files: []os.FileInfo{},
opts: Options{
SkipExtendedAttrs: true,
Recursive: false,
},
expectHasPreview: false,
expectMapNotEmpty: false,
},
{
name: "Recursive returns empty",
adjustedPath: "/test/",
opts: Options{SkipExtendedAttrs: false, Recursive: true},
name: "Recursive returns empty",
adjustedPath: "/test/",
combinedPath: "/test/",
files: []os.FileInfo{},
opts: Options{
SkipExtendedAttrs: false,
Recursive: true,
},
expectHasPreview: false,
expectMapNotEmpty: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.opts)
hasPreview, subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.files, tt.opts)
if hasPreview != tt.expectHasPreview {
t.Errorf("expected hasPreview=%v, got %v", tt.expectHasPreview, hasPreview)
}
if (len(subdirMap) > 0) != tt.expectMapNotEmpty {
t.Errorf("expected mapNotEmpty=%v, got %v", tt.expectMapNotEmpty, len(subdirMap) > 0)
}
@@ -43,28 +61,6 @@ 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 {
+143 -97
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, useLogicalSize bool) int64 {
func getDiskUsage(fileInfo os.FileInfo, realPath string, useLogicalSize bool) int64 {
// If useLogicalSize is true, return logical size (no block alignment)
if useLogicalSize {
if fileInfo.IsDir() {
@@ -63,10 +63,9 @@ 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
HideFileExt string // Hide files based on extensions
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
}
// ScannerInfo is the exposed scanner information for the client
@@ -95,27 +94,20 @@ type Stats struct {
type ReducedIndex struct {
Stats
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
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
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)
// 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
@@ -125,10 +117,6 @@ 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 (
@@ -316,14 +304,18 @@ 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 idx.getActiveScannerPathUnlocked() != "" {
if anyScannerActive || idx.getActiveScannerPathUnlocked() != "" {
return INDEXING
} else if allScannedAtLeastOnce {
return READY
@@ -364,7 +356,11 @@ func StopAllScanners() {
indexesMutex.Lock()
defer indexesMutex.Unlock()
for _, idx := range indexes {
idx.stopScheduler()
idx.mu.Lock()
for _, scanner := range idx.scanners {
scanner.stop()
}
idx.mu.Unlock()
}
}
@@ -405,7 +401,7 @@ func Initialize(source *settings.Source, mock bool, isNewDb bool) {
}
indexes[newIndex.Name] = &newIndex
indexesMutex.Unlock()
if !newIndex.Config.ResolvedRules.IndexingDisabled {
if !newIndex.Config.DisableIndexing {
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
@@ -433,10 +429,9 @@ type FileInfoRequest struct {
IndexPath string
FollowSymlinks bool
ShowHidden bool
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
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
@@ -540,7 +535,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)
size := idx.getFileSizeForDisplay(ctx.FileInfo, ctx.RealPath)
fileInfo := &iteminfo.FileInfo{
Path: ctx.IndexPath,
ItemInfo: iteminfo.ItemInfo{
@@ -573,7 +568,6 @@ 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)
@@ -624,7 +618,7 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
if err2 != nil {
return 0, false, err2
}
idx.UpdateMetadata(dirFileInfo, scanner, !opts.Recursive)
idx.UpdateMetadata(dirFileInfo, scanner)
// Always store the calculated directory size in the in-memory map
// This ensures parent directories are updated after copy/move operations
@@ -740,26 +734,50 @@ func (idx *Index) GetFsInfoViewableOnly(adjustedPath string, followSymlinks bool
})
}
// 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 {
// 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
subdirHasPreviewMap := make(map[string]bool)
if opts.SkipExtendedAttrs || opts.Recursive {
return subdirHasPreviewMap
return hasPreview, subdirHasPreviewMap
}
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, false)
if !exists {
return subdirHasPreviewMap
// Fetch hasPreview for current directory
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, true)
if exists {
hasPreview = realDirInfo.HasPreview
}
for _, folder := range realDirInfo.Folders {
key := utils.AddTrailingSlashIfNotExists(indexPath + folder.Name)
subdirHasPreviewMap[key] = folder.HasPreview
// 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)
}
return subdirHasPreviewMap
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
}
// processDirectoryItem processes a directory item and returns the itemInfo, size, and whether it should be counted
@@ -807,7 +825,11 @@ 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
@@ -829,7 +851,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))
size = uint64(idx.getFileSizeForDisplay(file, fullCombined))
shouldCountSize = true
} else {
size, shouldCountSize = idx.handleFile(file, indexPath, fullCombined, opts.IsRoutineScan, scanner)
@@ -840,7 +862,7 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
bubblesUpHasPreview := false
if !opts.SkipExtendedAttrs {
usedCachedPreview := false
if !idx.Config.ResolvedRules.IndexingDisabled && opts.Recursive {
if !idx.Config.DisableIndexing && opts.Recursive {
simpleType := strings.Split(itemInfo.Type, "/")[0]
if simpleType == "audio" {
previousInfo, exists := idx.GetReducedMetadata(fullCombined, false)
@@ -853,7 +875,9 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
if !usedCachedPreview {
setFilePreviewFlags(itemInfo, fullCombined)
}
bubblesUpHasPreview = iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo)
if itemInfo.HasPreview && iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo) {
bubblesUpHasPreview = true
}
} else {
itemInfo.HasPreview = false
}
@@ -870,7 +894,8 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
if err != nil {
return nil, err
}
subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, opts)
// Fetch extended attributes (hasPreview for current dir and subdirs)
hasPreview, subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, files, opts)
baseName := filepath.Base(indexPath)
if indexPath == "/" {
@@ -881,7 +906,6 @@ 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()
@@ -936,12 +960,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 bubblesUp {
hasPreview = true
}
if shouldCount {
totalSize += size
}
if bubblesUp {
hasPreview = true
}
fileInfos = append(fileInfos, iteminfo.ExtendedItemInfo{ItemInfo: *itemInfo})
if opts.IsRoutineScan {
idx.incrementScannerFiles()
@@ -1030,10 +1054,6 @@ 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 + "/"
}
@@ -1112,7 +1132,6 @@ 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)
}
@@ -1159,14 +1178,28 @@ func (idx *Index) GetFolderSizeForDisplay(path string) int64 {
}
// getFileSizeForDisplay returns file size with appropriate calculation based on config
func (idx *Index) getFileSizeForDisplay(file os.FileInfo) int64 {
func (idx *Index) getFileSizeForDisplay(file os.FileInfo, realPath string) int64 {
if idx.Config.UseLogicalSize {
// Logical size mode: return actual bytes
return file.Size()
}
// Disk usage mode: use getDiskUsage helper
return getDiskUsage(file, false)
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
}
}
// SyncFolderSizesToDB syncs in-memory folder sizes to the database after a scan completes
@@ -1356,7 +1389,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.ResolvedRules.IndexingDisabled || idx.Config.ResolvedRules.NoRules {
if idx.Config.DisableIndexing || idx.Config.ResolvedRules.NoRules {
return true
}
@@ -1379,7 +1412,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.ResolvedRules.IndexingDisabled {
if idx.Config.DisableIndexing {
return true
}
rules := idx.Config.ResolvedRules
@@ -1511,7 +1544,32 @@ func (idx *Index) getActiveScannerPath() string {
// getActiveScannerPathUnlocked returns the path of the currently active scanner, or empty string if none
func (idx *Index) getActiveScannerPathUnlocked() string {
return idx.activeScannerPath
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
}
// input should be non-index path.
@@ -1551,29 +1609,14 @@ func (idx *Index) shouldInclude(baseName string) bool {
return false
}
// 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.
// Save persists the index and scanner information to the database
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
return nil // No storage available, skip persistence
}
idx.mu.RLock()
// Collect scanner information
scanners := make(map[string]*dbindex.PersistedScannerInfo)
for path, scanner := range idx.scanners {
scanners[path] = &dbindex.PersistedScannerInfo{
@@ -1588,27 +1631,33 @@ func (idx *Index) writePersistedIndexInfo() 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,
Source: idx.Name,
Complexity: complexity,
NumDirs: numDirs,
NumFiles: numFiles,
UsedAsIndexed: usedAsIndexed,
UsedDisk: usedDisk,
DiskTotal: diskTotal,
Scanners: scanners,
Path: idx.Path, // Use real filesystem path as key
Source: idx.Name,
Complexity: complexity,
NumDirs: numDirs,
NumFiles: numFiles,
Scanners: scanners,
}
return indexingStorage.Save(info)
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
}
// Load restores index and scanner information from the database
@@ -1635,9 +1684,6 @@ 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
+2 -1
View File
@@ -29,7 +29,8 @@ func setupTestIndex(t *testing.T) (*Index, string, func()) {
Name: "test",
Path: "/mock/path",
Config: settings.SourceConfig{
UseLogicalSize: true,
DisableIndexing: false,
UseLogicalSize: true,
},
},
mock: true, // Enable mock mode
+165 -148
View File
@@ -1,7 +1,6 @@
package indexing
import (
"errors"
"os"
"path/filepath"
"strings"
@@ -16,24 +15,21 @@ import (
type Scanner struct {
scanPath string
// Adaptive scheduling (see Index.useAdaptiveScheduling)
currentSchedule int
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
smartModifier time.Duration
complexity uint
fullScanCounter int
nextSleepTime time.Duration
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
// 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
numDirs uint64
numFiles uint64
// Per-scanner state (not shared with other scanners)
processedInodes map[uint64]struct{} // Track inodes to detect hardlinks
@@ -41,6 +37,9 @@ 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()) {
@@ -55,46 +54,73 @@ func (s *Scanner) withStatsRLock(fn func()) {
fn()
}
// executeScan runs one scan for this scanner (invoked only from the per-index scheduler).
func (s *Scanner) executeScan() {
// 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
defer func() {
if r := recover(); r != nil {
logger.Errorf("[%s] Scanner panic recovered: %v", s.scanPath, r)
}
s.idx.mu.Lock()
s.idx.activeScannerPath = ""
batch := s.idx.schedulerBatch > 0
s.isScanning = false
s.idx.mu.Unlock()
if !batch {
err := s.idx.PostScan()
if err != nil {
logger.Errorf("Failed to post scan: %v", err)
}
err := s.idx.PostScan()
if err != nil {
logger.Errorf("Failed to post scan: %v", err)
}
}()
if s.scanPath != "/" && !s.directoryExists() {
s.handlePathGone("pre-scan check")
return
}
s.prepareScanState()
// Set isScanning flag for all scanners
s.idx.mu.Lock()
s.idx.activeScannerPath = s.scanPath
s.isScanning = true
s.idx.mu.Unlock()
// 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
}
})
// 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
}
s.runIndexing(quick)
s.updateSchedule()
}
@@ -169,14 +195,13 @@ func (s *Scanner) runFullScanRoot() {
s.purgeStaleEntries(true, false) // isRoot=true, isQuickScan=false
s.syncStatsWithDB()
s.resetHardlinkTracking()
if s.processedInodes != nil {
s.processedInodes = nil
s.foundHardLinks = nil
}
s.idx.mu.Lock()
if s.idx.scanUpdatedPaths == nil {
s.idx.scanUpdatedPaths = make(map[string]bool)
} else {
clear(s.idx.scanUpdatedPaths)
}
s.idx.scanUpdatedPaths = make(map[string]bool)
s.idx.mu.Unlock()
}
@@ -274,10 +299,6 @@ 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()
@@ -295,7 +316,10 @@ func (s *Scanner) runFullScanChild() {
}
}
s.resetHardlinkTracking()
if s.processedInodes != nil {
s.processedInodes = nil
s.foundHardLinks = nil
}
s.idx.mu.Lock()
for path := range s.idx.scanUpdatedPaths {
@@ -315,9 +339,6 @@ 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 {
@@ -329,6 +350,25 @@ 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
@@ -376,9 +416,11 @@ func (s *Scanner) checkForNewChildDirectories() {
}
s.idx.mu.RUnlock()
for path := range existingScanners {
for path, scanner := range existingScanners {
if !currentDirsMap[path] {
logger.Debugf("Directory [%s] no longer exists, removing scanner", path)
logger.Debugf("Directory [%s] no longer exists, stopping scanner", path)
scanner.stop()
// Remove from map immediately to prevent stale scanner references
s.idx.mu.Lock()
delete(s.idx.scanners, path)
s.idx.mu.Unlock()
@@ -395,6 +437,8 @@ func (s *Scanner) checkForNewChildDirectories() {
s.idx.mu.Lock()
s.idx.scanners[dirPath] = newScanner
s.idx.mu.Unlock()
go newScanner.start()
}
}
}
@@ -442,99 +486,85 @@ func (s *Scanner) getTopLevelDirs() []string {
return dirs
}
// updateSchedule adjusts the scanner's schedule based on whether files changed (adaptive mode only).
func (s *Scanner) updateSchedule() {
if !s.idx.useAdaptiveScheduling() {
return
// 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
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
}
// Adjust schedule based on file changes
if s.filesChanged {
logger.Debugf("Scanner [%s] detected changes, adjusting schedule", s.scanPath)
// Floor at 40-minute tier (index 3)
// Move to at least schedule index 3 or reduce interval
if s.currentSchedule > 3 {
s.currentSchedule = 3
} else if s.currentSchedule > 0 {
s.currentSchedule--
}
} else {
if s.currentSchedule < len(scanScheduleTiers)-1 {
// Increment toward the longest interval if no changes
if s.currentSchedule < len(scanSchedule)-1 {
s.currentSchedule++
}
}
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()
// 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
}
now := time.Now()
nextRun := computeNextSlotTime(lastScanned, now, intervalForNext)
// Ensure currentSchedule stays within bounds
if s.currentSchedule < 0 {
s.currentSchedule = 0
} else if s.currentSchedule >= len(scanSchedule) {
s.currentSchedule = len(scanSchedule) - 1
}
s.statsMu.Lock()
s.nextRun = nextRun
s.statsMu.Unlock()
s.idx.registerScannerNextRun(s, nextRun)
// 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)
}
}
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
// 0: unknown
func (s *Scanner) updateComplexity() {
s.statsMu.Lock()
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
s.statsMu.Unlock()
defer s.statsMu.Unlock()
// Persist after full scans — release statsMu before Save (same ordering as updateSchedule).
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
// 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)
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
@@ -546,10 +576,6 @@ 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()
@@ -557,6 +583,14 @@ 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
@@ -590,10 +624,7 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
info, err := os.Stat(realPath)
if err != nil {
if os.IsNotExist(err) {
if folderPath == s.scanPath {
s.handlePathGone("quick scan")
}
// Subfolder deleted — stale entries are removed by purge
// Folder was deleted - will be caught by purge
return false, nil
}
logger.Errorf("[QUICK_SCAN] Error stating %s: %v", folderPath, err)
@@ -629,13 +660,10 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
}
// Update metadata (will be batched)
s.idx.UpdateMetadata(dirInfo, s, true)
s.idx.UpdateMetadata(dirInfo, s)
// 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()
@@ -644,26 +672,15 @@ 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
// BulkInsertItems overwrites all columns — preserve size/hasPreview/hidden from cache/DB, not hardcoded defaults.
var sizeForDB int64
if cached, ok := s.idx.GetFolderSize(folderPath); ok {
sizeForDB = int64(cached)
}
hasPreview := false
hidden := IsHidden(realPath)
if existing, ok := s.idx.GetReducedMetadata(folderPath, true); ok && existing != nil {
hasPreview = existing.HasPreview
hidden = existing.Hidden
}
fileInfo := &iteminfo.FileInfo{
Path: folderPath,
ItemInfo: iteminfo.ItemInfo{
Name: filepath.Base(strings.TrimSuffix(folderPath, "/")),
Size: sizeForDB,
Size: 0, // Folder size is managed in-memory via folderSizes map
ModTime: info.ModTime(),
Type: "directory",
Hidden: hidden,
HasPreview: hasPreview,
Hidden: false,
HasPreview: false,
},
}
-115
View File
@@ -1,115 +0,0 @@
package indexing
import (
"os"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
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")
}
}
// TestCheckFolderModtime_unchangedPreservesHasPreviewAndHidden verifies quick-scan touch updates
// do not clobber has_preview/hidden when folder modtime is unchanged.
func TestCheckFolderModtime_unchangedPreservesHasPreviewAndHidden(t *testing.T) {
if indexDB == nil {
var err error
indexDB, _, err = dbsql.NewIndexDB("test_scanner_modtime", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
}
root := t.TempDir()
photosDir := root + "/photos"
if err := os.Mkdir(photosDir, 0o755); err != nil {
t.Fatalf("mkdir photos: %v", err)
}
idx := &Index{
ReducedIndex: ReducedIndex{},
Source: settings.Source{
Name: "test_modtime",
Path: root,
},
db: indexDB,
scanUpdatedPaths: make(map[string]bool),
folderSizes: make(map[string]uint64),
folderSizesUnsynced: make(map[string]struct{}),
}
folderPath := "/photos/"
now := time.Now()
stored := &iteminfo.FileInfo{
Path: folderPath,
ItemInfo: iteminfo.ItemInfo{
Name: "photos",
Type: "directory",
Size: 42,
ModTime: now.Add(-time.Hour),
Hidden: true,
HasPreview: true,
},
}
if err := idx.db.InsertItem("test_modtime", folderPath, stored); err != nil {
t.Fatalf("InsertItem: %v", err)
}
idx.SetFolderSize(folderPath, 42)
s := &Scanner{
scanPath: "/",
idx: idx,
}
s.withStatsLock(func() {
s.lastScanned = time.Now()
})
s.batchItems = make([]*iteminfo.FileInfo, 0, idx.db.BatchSize)
changed, err := s.checkFolderModtime(folderPath)
if err != nil {
t.Fatalf("checkFolderModtime: %v", err)
}
if changed {
t.Fatal("expected unchanged folder")
}
s.flushBatch()
got, ok := idx.GetReducedMetadata(folderPath, true)
if !ok || got == nil {
t.Fatal("expected folder metadata after touch update")
}
if !got.HasPreview {
t.Error("HasPreview should be preserved on unchanged modtime touch update")
}
if !got.Hidden {
t.Error("Hidden should be preserved on unchanged modtime touch update")
}
}
+39 -75
View File
@@ -5,57 +5,38 @@ 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"
)
// 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,
// 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,
}
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
// 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
}
// calculateTimeScore returns a 1-10 score based on scan time
@@ -191,11 +172,13 @@ func (idx *Index) incrementScannerFiles() {
}
}
func (idx *Index) PreScan() error {
return idx.SetStatus(INDEXING)
}
func (idx *Index) PostScan() error {
idx.mu.RLock()
idle := idx.activeScannerPath == ""
idx.mu.RUnlock()
if idle {
// Only do expensive operations when ALL scanners are done
if idx.getRunningScannerCount() == 0 {
// All scanners completed
err := idx.db.ShrinkMemory()
if err != nil {
@@ -222,9 +205,7 @@ func (idx *Index) SendSourceUpdateEvent() error {
if idx.mock {
return nil
}
// 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)
reducedIndex, err := GetIndexInfo(idx.Name, true)
if err != nil {
logger.Errorf("[%s] Error getting index info: %v", idx.Name, err)
return err
@@ -245,8 +226,7 @@ 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-level stats from Bolt (complexity, numDirs/Files, used/usedAlt/total, etc.)
// Scanner-level stats below use a second GetByPath only for the Scanners map.
// Load persisted index and scanner information
if err := idx.Load(); err != nil {
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
}
@@ -266,27 +246,23 @@ 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
if adaptive {
rootScanner.currentSchedule = utils.Clamp(rootInfo.CurrentSchedule, 0, len(scanScheduleTiers)-1)
}
rootScanner.currentSchedule = rootInfo.CurrentSchedule
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()
@@ -300,15 +276,12 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
if childInfo, ok := persistedScanners[dirPath]; ok {
childScanner.withStatsLock(func() {
childScanner.complexity = childInfo.Complexity
if adaptive {
childScanner.currentSchedule = utils.Clamp(childInfo.CurrentSchedule, 0, len(scanScheduleTiers)-1)
}
childScanner.currentSchedule = childInfo.CurrentSchedule
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)
})
}
}
@@ -316,17 +289,10 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
idx.mu.Lock()
idx.scanners[dirPath] = childScanner
idx.mu.Unlock()
}
if idx.useAdaptiveScheduling() {
idx.mu.Lock()
idx.schedulerStartupPassPending = true
idx.mu.Unlock()
go childScanner.start()
}
idx.restoreScannerNextRuns()
go idx.runIndexScheduler()
logger.Debugf("Created %d scanners for [%v] (1 root + %d children)", len(topLevelDirs)+1, idx.Name, len(topLevelDirs))
}
@@ -335,6 +301,7 @@ 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
@@ -368,10 +335,7 @@ func (idx *Index) GetScannerStatus() map[string]interface{} {
scannerInfo["numDirs"] = scanner.numDirs
scannerInfo["numFiles"] = scanner.numFiles
scannerInfo["filesChanged"] = scanner.filesChanged
scannerInfo["fullScanCounter"] = scanner.fullScanCounter
if !scanner.nextRun.IsZero() {
scannerInfo["nextRun"] = scanner.nextRun.Format(time.RFC3339)
}
scannerInfo["smartModifier"] = scanner.smartModifier.String()
})
scannerStats = append(scannerStats, scannerInfo)
}
-354
View File
@@ -1,354 +0,0 @@
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++
idx.mu.Unlock()
defer func() {
idx.mu.Lock()
idx.schedulerBatch--
idx.mu.Unlock()
if err := idx.PostScan(); err != nil {
logger.Errorf("PostScan after serial pass: %v", err)
}
}()
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).Truncate(time.Second)
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)
}
})
}
@@ -1,69 +0,0 @@
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)
}
}
+10 -8
View File
@@ -67,13 +67,15 @@ 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{},
Name: "test-multiscanner",
Path: "/tmp/test",
Config: settings.SourceConfig{
DisableIndexing: false,
},
},
mock: true,
db: indexDB,
scanners: make(map[string]*Scanner),
mock: true,
db: indexDB,
scanners: make(map[string]*Scanner),
}
// Create multiple scanners
@@ -111,8 +113,8 @@ func TestMultiScannerMutex(t *testing.T) {
}
// Verify scanner counters can be updated
// Test with active scanner
idx.activeScannerPath = "/test1/"
// Test with active scanner - mark scanner2 as scanning
scanner2.isScanning = true
idx.scanners = map[string]*Scanner{"/test1/": scanner2}
idx.incrementScannerDirs()
idx.incrementScannerFiles()
+8 -19
View File
@@ -30,28 +30,18 @@ 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)
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
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)
}
// for efficiency, a response will be a pointer to the data
@@ -67,5 +57,4 @@ type ExtendedFileInfo struct {
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
RealPath string `json:"-"`
PinnedItems []string `json:"pinnedItems,omitempty"` // pinned item names in this directory listing
}
+26 -19
View File
@@ -317,33 +317,28 @@ 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
@@ -353,6 +348,7 @@ func (fi ItemInfo) MatchesSearchAuxiliaryFilters(options SearchOptions) bool {
fileTypes["dir"] = isDir
fileSize = fi.Size
// Evaluate all conditions
for t, v := range conditions {
if t == "exact" {
continue
@@ -371,6 +367,7 @@ func (fi ItemInfo) MatchesSearchAuxiliaryFilters(options SearchOptions) bool {
}
}
default:
// Handle other file type conditions
notMatchType := v != fileTypes[t]
if notMatchType {
return false
@@ -408,10 +405,20 @@ 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 {
// Get the simple type (e.g., "image", "video", "text")
simpleType := strings.Split(item.Type, "/")[0]
// Text files should NOT bubble up
if simpleType == "text" {
return false
}
// Office documents should NOT bubble up
if IsOnlyOffice(item.Name) {
return false
}
// Document convertable files (PDFs, etc.) should NOT bubble up
if HasDocConvertableExtension(item.Name, item.Type) {
return false
}
simpleType := strings.Split(item.Type, "/")[0]
// Only allow images, videos, and audio with album art to bubble up
if simpleType == "image" || simpleType == "video" || simpleType == "audio" {
return true
@@ -1,69 +0,0 @@
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 369 files, more files were not shown because too many files have changed in this diff. Show more