Compare commits

...
37 Commits
Author SHA1 Message Date
Kevin B.andGraham Steffaniak a6deb9b8ab fix gallery slider and "too much recursion" error when navigating back (#2416)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2026-05-13 11:53:54 -05:00
Graham Steffaniak 215a3fd858 updated backend user default references (#2420) 2026-05-13 11:13:36 -05:00
Graham Steffaniak 9c5df98b79 updated screenshots 2026-05-13 10:11:33 -05:00
Graham Steffaniak 0382bec1cd add screenshot scrolling changes 2026-05-13 09:40:05 -05:00
Graham Steffaniak 532632de1c v1.4.0 update dependencies (#2415) 2026-05-12 18:25:50 -05:00
Graham Steffaniak 6c72d1b85f updated sidebar toggle button behavior (#2414) 2026-05-12 16:37:24 -05:00
Graham Steffaniak f4587d8ef3 updated changelog 2026-05-12 09:41:40 -05:00
Graham Steffaniak 3db63bd44c Merge branch 'dev/v1.4.0' of github.com:gtsteffaniak/filebrowser into dev/v1.4.0 2026-05-12 09:41:22 -05:00
Juan SolerandGraham Steffaniak 708a40b788 feat: add WebAuthn/Passkey multi-factor authentication support (#2379)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-05-12 09:40:56 -05:00
Graham Steffaniak fa40a7472d Merge branch 'dev/v1.4.0' of github.com:gtsteffaniak/filebrowser into dev/v1.4.0 2026-05-12 09:39:48 -05:00
Kevin B.andGraham Steffaniak 548221c28c Some small fixes (#2407)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-05-12 09:39:17 -05:00
Graham SteffaniakandStephan Paternotte 77d3ab9554 Reorder user defaults (#2412)
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-05-12 09:24:20 -05:00
Graham Steffaniak e2f27d1391 Merge branch 'dev/v1.4.0' of github.com:gtsteffaniak/filebrowser into dev/v1.4.0 2026-05-11 17:17:59 -05:00
Graham SteffaniakandStephan Paternotte a8f2477879 Add sidebar link (#2406)
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-05-11 17:17:47 -05:00
Graham Steffaniak d70ed3d806 Merge branch 'dev/v1.4.0' of github.com:gtsteffaniak/filebrowser into dev/v1.4.0 2026-05-11 16:23:51 -05:00
Graham SteffaniakandStephan Paternotte 786cabb666 Merge main v1.4.0 (#2410)
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-05-11 16:11:15 -05:00
Graham Steffaniak d36c0bf8f9 Merge branch 'main' into dev/v1.4.0 2026-05-11 15:52:09 -05:00
Kevin B. 8f78112a94 Option to hide files in UI by extension (#2403) 2026-05-11 15:35:54 -05:00
Graham Steffaniak a4d20c8101 added advanced search tool (#2401) 2026-05-10 18:30:25 -05:00
Kevin B. d0f5cc3593 Add missing copy path option to UserDefaults in config (#2402) 2026-05-09 08:48:55 -05:00
Graham Steffaniak e8c8ea2454 updated readme 2026-05-06 15:39:41 -05:00
Graham Steffaniak 08fc72de8c Merge branch 'dev/v1.4.0' of github.com:gtsteffaniak/filebrowser into dev/v1.4.0 2026-05-06 15:37:00 -05:00
09bfc919d6 Fix user scope issue 2 (#2394)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-05-06 15:27:36 -05:00
ad13b56d7f Standardize path picker (#2393)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-05-06 15:17:15 -05:00
Graham Steffaniak 6aae66551b Merge branch 'dev/v1.4.0' of github.com:gtsteffaniak/filebrowser into dev/v1.4.0 2026-05-06 14:08:20 -05:00
ff72ca3061 Support wildcard search (#2392)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-05-06 09:36:33 -05:00
595e167d47 Add table style (#2391)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-05-05 20:08:21 -05:00
Graham Steffaniak 9afc5f0017 Merge branch 'dev/v1.4.0' of github.com:gtsteffaniak/filebrowser into dev/v1.4.0 2026-05-05 16:46:45 -05:00
6bb451758a Merge v1.4.0 v1.3.9 (#2378)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-05-01 16:58:50 -05:00
Graham Steffaniak 54cdea3391 Merge branch 'main' into dev/v1.4.0 2026-05-01 16:13:24 -05:00
Graham Steffaniak 4b97b19a21 Merge branch 'dev/v1.4.0' of github.com:gtsteffaniak/filebrowser into dev/v1.4.0 2026-05-01 16:12:32 -05:00
Kevin B.andGraham Steffaniak 0fff81583d Support to display embedded lyrics from audio files (#2360)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2026-05-01 13:49:37 -05:00
14ada6bba8 Merge v1.3.8 v1.4.0 (#2369)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-30 15:07:05 -05:00
Graham Steffaniak 9b684ba40d Merge branch 'main' into dev/v1.4.0 2026-04-30 15:01:05 -05:00
Kevin B. 977f64b248 add more shortcuts and save view modes and sizes to local storage (#2300) 2026-04-18 18:32:57 -05:00
95eb5da737 Filter logs based on regex (#2299)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-15 16:26:19 -05:00
Graham SteffaniakandKevin B. 3bb4c099c1 Optimized scheduler (#2290)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-04-15 14:02:32 -05:00
169 changed files with 13099 additions and 2803 deletions

No files matched your search

+44
View File
@@ -2,6 +2,50 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.4.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**:
+1 -1
View File
@@ -1,7 +1,7 @@
<div align="center">
[![Go Report Card](https://goreportcard.com/badge/github.com/gtsteffaniak/filebrowser/backend)](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/0b548794f2ac4871a0cf7aa9ecab049f)](https://app.codacy.com/gh/gtsteffaniak/filebrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
[![latest version](https://img.shields.io/github/v/release/gtsteffaniak/filebrowser)](https://github.com/gtsteffaniak/filebrowser/releases)
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
@@ -2,7 +2,8 @@ server-main: &server-main
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: '.*'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
+2 -1
View File
@@ -1,7 +1,8 @@
server:
port: 8080
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: '.*'
sources:
- path: "../frontend/tests/playwright-files"
config:
+2 -1
View File
@@ -2,7 +2,8 @@ server:
port: 80
baseURL: "/files/"
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: '.*'
sources:
- path: "../frontend/tests/playwright-files"
name: "exclude"
+1
View File
@@ -1,6 +1,7 @@
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: '.*'
sources:
- path: "../frontend/tests/playwright-files"
config:
+2 -1
View File
@@ -2,7 +2,8 @@ server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: '.*'
sources:
- path: "../frontend/tests/playwright-files"
+2 -1
View File
@@ -2,7 +2,8 @@ server:
port: 8080
baseURL: "/subpath"
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: '.*'
sources:
- path: "../frontend/tests/playwright-files"
config:
+2 -1
View File
@@ -2,7 +2,8 @@ server:
port: 8080
baseURL: "/files/"
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: '.*'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright"
+2 -1
View File
@@ -2,7 +2,8 @@ server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: '.*'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
+2 -1
View File
@@ -2,7 +2,8 @@ server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: '.*'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
+1 -1
View File
@@ -684,7 +684,7 @@ func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
}
indexing.StopAllScanners()
// Give scanners time to finish their cleanup (defer blocks in tryAcquireAndScan)
// Give scanners time to finish their cleanup (defer blocks in executeScan)
time.Sleep(100 * time.Millisecond)
indexing.ClearTestIndices()
t.Logf("iteration %d: cleaned scanners and indices", iter)
+128 -3
View File
@@ -1,11 +1,13 @@
package files
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -46,7 +48,7 @@ func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileO
wg.Go(func() {
fullPath := response.RealPath + "/" + fileItem.Name
if isAudio {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, false, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
@@ -153,6 +155,20 @@ type Items struct {
Folders []string `json:"folders,omitempty"`
}
// This removes files whose names match any extension in hideFileExt.
func filterFilesByExt(files []iteminfo.ExtendedItemInfo, hideFileExt string) []iteminfo.ExtendedItemInfo {
if hideFileExt == "" {
return files
}
filtered := files[:0]
for _, f := range files {
if !utils.HideFileByExt(f.Name, hideFileExt) {
filtered = append(filtered, f)
}
}
return filtered
}
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
items := Items{}
indexPath, _, err := CheckPermissions(opts, access, user)
@@ -169,6 +185,7 @@ func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.Use
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
HideFileExt: opts.HideFileExt,
Expand: true,
SkipExtendedAttrs: true,
})
@@ -183,6 +200,9 @@ func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.Use
}
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)
}
}
@@ -219,6 +239,7 @@ 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,
})
@@ -250,11 +271,17 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
}
defer finalizeResponse(response, info, response.RealPath, user, userScope)
if opts.SkipExtendedAttrs {
if info.Type == "directory" && opts.HideFileExt != "" {
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
}
return response, nil
}
// Process directory metadata if requested
if info.Type == "directory" {
processDirectoryMetadata(response, idx, opts)
if opts.HideFileExt != "" {
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
}
}
// Process single file content/metadata
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
@@ -306,7 +333,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, false, nil)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
@@ -340,10 +367,58 @@ func generateOfficeId(realPath string) string {
return key
}
// Parses raw LRC text into a slice of Lyric.
// Lines with [hh:mm:ss.xx] (timestamps) will be parsed to ms for synced lyrics
// The ones without timestamp are kept as unsynced (timestamp 0).
func parseLRC(raw string) []iteminfo.Lyric {
var lines []iteminfo.Lyric
scanner := bufio.NewScanner(strings.NewReader(raw))
re := regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
matches := re.FindStringSubmatch(line)
if len(matches) == 6 {
// Hours default to 0 (if not present in the timestamps)
h := 0
if matches[1] != "" {
h, _ = strconv.Atoi(matches[1])
}
min, _ := strconv.Atoi(matches[2])
sec, _ := strconv.Atoi(matches[3])
msStr := matches[4]
// Normalise to 3 digits, some songs have more than 3 digits in the milliseconds
switch {
case len(msStr) < 3:
msStr += strings.Repeat("0", 3-len(msStr))
case len(msStr) > 3:
msStr = msStr[:3]
}
ms, _ := strconv.Atoi(msStr)
timestamp := int64((h*3600+min*60+sec)*1000 + ms)
text := strings.TrimSpace(matches[5])
lines = append(lines, iteminfo.Lyric{
Text: text,
Timestamp: timestamp,
})
} else {
// Line without a valid timestamp – treat as unsynced (timestamp 0)
lines = append(lines, iteminfo.Lyric{
Text: line,
Timestamp: 0,
})
}
}
return lines
}
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
// and optionally extracts duration using the ffmpeg service with concurrency control
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
file, err := os.Open(realPath)
if err != nil {
return err
@@ -379,6 +454,28 @@ 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 .lrc file
ext := filepath.Ext(realPath)
base := strings.TrimSuffix(realPath, ext)
lrcPath := base + ".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 {
@@ -411,6 +508,34 @@ 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 {
ext := filepath.Ext(realPath)
base := strings.TrimSuffix(realPath, ext)
lrcPath := base + ".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 {
+1
View File
@@ -446,6 +446,7 @@ var types = map[string]string{
".ass": "text/subtitle-ass",
".vtt": "text/subtitle-vtt",
".ssa": "text/subtitle-ssa",
".lrc": "text/lyrics",
".sbv": "text/plain",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
+5 -1
View File
@@ -25,7 +25,11 @@ func MakeUserDirs(u *users.User, createDir bool) error {
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
}
for i, scope := range u.Scopes {
source, ok := settings.Config.Server.SourceMap[scope.Name]
info, ok := users.ResolveSourceKey(scope.Name)
if !ok {
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
}
source, ok := settings.Config.Server.SourceMap[info.Path]
if !ok {
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
}
+306
View File
@@ -0,0 +1,306 @@
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{}
originMu sync.Mutex
}
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
}
// EnsureOrigin dynamically adds the given origin to the allowed RP origins list.
// This supports self-hosted deployments where the access domain may vary.
func (s *WebAuthnService) EnsureOrigin(origin string) {
s.originMu.Lock()
defer s.originMu.Unlock()
for _, o := range s.wa.Config.RPOrigins {
if o == origin {
return
}
}
s.wa.Config.RPOrigins = append(s.wa.Config.RPOrigins, origin)
}
// EnsureRPID dynamically updates the relying party ID to match the request host.
func (s *WebAuthnService) EnsureRPID(rpID string) {
s.originMu.Lock()
defer s.originMu.Unlock()
s.wa.Config.RPID = rpID
}
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
}
+12 -77
View File
@@ -110,14 +110,13 @@ func runCLI() (bool, bool) {
}
}
dbExists := getStore(storeCfg)
switch cliSubcmd {
case "setup":
createConfig(configPath)
return false, dbExists
return false, false
case "set":
if setKind == "-u" {
dbExists := getStore(storeCfg)
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
err := setUser(dbConfig, asAdmin)
if err != nil {
@@ -128,6 +127,7 @@ 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,8 +143,9 @@ 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, dbExists
return false, false
default:
dbExists := getStore(storeCfg)
return true, dbExists
}
}
@@ -225,7 +226,7 @@ func setUser(dbConfig string, asAdmin bool) error {
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
Name: source.Path,
Scope: source.Config.DefaultUserScope,
})
}
@@ -237,7 +238,7 @@ func setUser(dbConfig string, asAdmin bool) error {
} else {
logger.Infof("Creating non-admin user: %s\n", username)
}
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Account.Permissions)
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser, newUser.Permissions)
if err != nil {
@@ -258,7 +259,7 @@ func setUser(dbConfig string, asAdmin bool) error {
}
// Ensure version is set for existing users being updated
if user.Version == 0 {
user.Version = 1
user.Version = users.CurrentUserMigrationVersion
}
err = store.Users.Save(user, true, false)
if err != nil {
@@ -268,51 +269,6 @@ func setUser(dbConfig string, asAdmin bool) error {
return nil
}
// UserDefaults defines default settings for new users.
type UserDefaults struct {
Permissions users.Permissions `yaml:"permissions"`
}
// Frontend defines settings related to the web interface.
type Frontend struct {
Name string `yaml:"name,omitempty"`
}
// Source defines a directory to be served.
type Source struct {
Name string `yaml:"name,omitempty"`
Path string `yaml:"path"`
}
// Server defines server-specific configurations.
type Server struct {
Port int `yaml:"port"`
Database string `yaml:"database"`
Sources []Source `yaml:"sources"`
Logging []LogConfig `json:"logging"`
}
type LogConfig struct {
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Utc bool `json:"utc"` // use UTC time in the output instead of local time
}
type Auth struct {
AdminUsername string `yaml:"adminUsername"`
AdminPassword string `yaml:"adminPassword"`
}
// Settings is the top-level configuration structure.
type Settings struct {
Server Server `yaml:"server"`
Frontend Frontend `yaml:"frontend,omitempty"`
Auth Auth `yaml:"auth"`
UserDefaults UserDefaults `yaml:"userDefaults"`
}
// askQuestion displays a prompt and reads a line of input from the user.
// It returns the defaultValue if the user's input is empty.
func askQuestion(reader *bufio.Reader, prompt string, defaultValue string) string {
@@ -344,30 +300,9 @@ func askYesNoQuestion(reader *bufio.Reader, prompt string, defaultValue string)
// createConfig orchestrates the configuration process by asking the user a series of questions.
func createConfig(configpath string) {
// check if config file exists
if _, err := os.Stat("config.yaml"); err == nil {
fmt.Println("Config file 'config.yaml' already exists, skipping setup.")
return
}
reader := bufio.NewReader(os.Stdin)
config := Settings{
Server: Server{
Logging: []LogConfig{
{
Levels: "info|warning|error",
ApiLevels: "info|warning|error",
Output: "stdout",
NoColors: false,
Utc: false,
},
},
Sources: []Source{
{
Path: "",
},
},
},
}
config := settings.SetDefaults(false)
config.Server.Sources = []*settings.Source{{Path: "./"}}
fmt.Println("--- Starting Configuration Setup ---")
realPath := ""
@@ -434,8 +369,8 @@ func createConfig(configpath string) {
config.Auth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
// 6. Ask boolean (Yes/No) questions using the helper
config.UserDefaults.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.UserDefaults.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
config.UserDefaults.Account.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.UserDefaults.Account.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
fmt.Println("--- Configuration Complete ---")
+6
View File
@@ -12,6 +12,7 @@ import (
_ "net/http/pprof"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
@@ -219,6 +220,11 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
// Initialize PWA manifest after icons are generated
icons.InitializePWAManifest()
// Initialize WebAuthn/Passkey service if enabled
if err := auth.InitWebAuthn(); err != nil {
logger.Fatalf("Failed to initialize WebAuthn: %v", err)
}
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+41 -27
View File
@@ -44,6 +44,9 @@ 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 {
@@ -62,7 +65,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"}
fields := []string{"Scopes", "SidebarLinks", "Tokens", "Permissions", "Preview", "ShowFirstLogin", "LoginMethod", "Version", "ShowToolsInSidebar"}
if changePass {
fields = append(fields, "Password")
}
@@ -78,10 +81,12 @@ func updateUserScopes(user *users.User) bool {
newScopes := []users.SourceScope{}
seen := make(map[string]struct{})
// Build map for existing scopes by Name
// Build map of existing scopes keyed by canonical source path (DB may use path or display name).
existing := make(map[string]users.SourceScope)
for _, s := range user.Scopes {
existing[s.Name] = s
if info, ok := users.ResolveSourceKey(s.Name); ok {
existing[info.Path] = s
}
}
// Preserve order by using Config.Server.Sources
@@ -104,17 +109,30 @@ func updateUserScopes(user *users.User) bool {
seen[src.Path] = struct{}{}
}
// Preserve user-defined scopes not matching current sources, append to end
// Preserve explicit or legacy scopes not already merged (e.g. non-defaultEnabled sources, removed sources).
for _, s := range user.Scopes {
if _, ok := seen[s.Name]; !ok {
newScopes = append(newScopes, s)
if info, ok := users.ResolveSourceKey(s.Name); ok {
if _, already := seen[info.Path]; already {
continue
}
}
newScopes = append(newScopes, s)
}
changed := !reflect.DeepEqual(user.Scopes, newScopes)
user.Scopes = newScopes
return changed
}
// updateShowToolsInSidebar one-time defaults for legacy users (Version < CurrentUserMigrationVersion) from configured userDefaults.
func updateShowToolsInSidebar(user *users.User) bool {
if user.Version >= 3 {
return false
}
user.ShowToolsInSidebar = true
user.Version = users.CurrentUserMigrationVersion
return true
}
func updateShowFirstLogin(user *users.User) bool {
if user.ShowFirstLogin && !settings.Env.IsFirstLoad {
user.ShowFirstLogin = false
@@ -129,7 +147,6 @@ func updatePermissions(user *users.User) bool {
return false
}
updateUser := true
user.Permissions.Download = true
// if any keys are true, set the permissions to true
if user.Perm.Api {
user.Permissions.Api = true
@@ -171,7 +188,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = 2
user.Version = users.CurrentUserMigrationVersion
if updateUser {
createBackup = true
}
@@ -205,11 +222,11 @@ func updateSidebarLinks(user *users.User) bool {
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
sourceLinksCount++
// Check if this source still exists
if link.SourceName != "" {
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
validSourceLinksCount++
}
if link.SourceName == "" {
continue
}
if _, ok := users.ResolveSourceKey(link.SourceName); ok {
validSourceLinksCount++
}
}
}
@@ -221,8 +238,6 @@ func updateSidebarLinks(user *users.User) bool {
// If user has source links but NONE are valid, rebuild from their scopes
if validSourceLinksCount == 0 {
logger.Infof("User %s has %d stale source links, rebuilding from scopes", user.Username, sourceLinksCount)
// Remove all existing source links
newLinks := []users.SidebarLink{}
for _, link := range user.SidebarLinks {
@@ -231,25 +246,24 @@ func updateSidebarLinks(user *users.User) bool {
}
}
// Add new source links based on user's scopes
for _, scope := range user.Scopes {
if source, ok := settings.Config.Server.SourceMap[scope.Name]; ok {
// User has access to this source, add it to sidebar
newLinks = append(newLinks, users.SidebarLink{
Name: source.Name,
Category: "source",
Target: "/",
Icon: "",
SourceName: source.Path,
})
info, ok := users.ResolveSourceKey(scope.Name)
if !ok {
continue
}
newLinks = append(newLinks, users.SidebarLink{
Name: info.Name,
Category: "source",
Target: "/",
Icon: "",
SourceName: info.Path,
})
}
user.SidebarLinks = newLinks
return true
}
// User has at least one valid source link, no update needed
return false
}
@@ -264,6 +278,6 @@ func updateTokens(user *users.User) bool {
user.Tokens[name] = token
}
}
user.Version = 2
user.Version = users.CurrentUserMigrationVersion
return true
}
+1
View File
@@ -25,6 +25,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
"sourceA": &sourceA,
"sourceB": &sourceB,
}
settings.InitializeUserResolvers()
// ---------------------
// Test Cases Definition
+6
View File
@@ -26,4 +26,10 @@ 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,63 +19,78 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{
EditorQuickSave: true,
HideSidebarFileActions: true,
DisableQuickToggles: true,
DisableSearchOptions: true,
StickySidebar: false,
HideFilesInTree: true,
DarkMode: &dark,
Locale: "de",
ViewMode: "list",
SingleClick: true,
ShowHidden: true,
DateFormat: true,
GallerySize: 7,
ThemeColor: "var(--red)",
QuickDownload: true,
DisablePreviewExt: ".exe",
DisableViewingExt: ".bat",
DisableSettings: true,
LockPassword: true,
DisableUpdateNotifications: true,
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
DisableOfficePreviewExt: "legacy",
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
DebugOffice: true,
PreferEditorForMarkdown: true,
LoginMethod: "password",
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
DownloadChunkSize: 2,
Sidebar: UserDefaultsSidebar{
DisableQuickToggles: true,
HideFileActions: true,
DisableHideOnPreview: true,
Sticky: false,
HideFiles: true,
ShowTools: boolPtr(true),
},
Permissions: UserDefaultsPermissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: &dlFalse,
Listing: UserDefaultsListing{
DeleteWithoutConfirming: true,
DateFormat: true,
ShowHidden: true,
QuickDownload: true,
ShowSelectMultiple: true,
SingleClick: true,
HideFileExt: "",
ShowCopyPath: false,
DeleteAfterArchive: false,
ViewMode: "list",
GallerySize: 7,
},
Preview: UserDefaultsPreview{
DisableHideSidebar: true,
Image: boolPtr(false),
Video: boolPtr(false),
Audio: boolPtr(false),
MotionVideoPreview: boolPtr(false),
Office: boolPtr(false),
PopUp: boolPtr(false),
AutoplayMedia: boolPtr(false),
DefaultMediaPlayer: true,
DisablePreviewExt: ".exe",
HighQuality: boolPtr(true),
Folder: boolPtr(false),
Models: boolPtr(false),
},
FileViewer: UserDefaultsFileViewer{
EditorQuickSave: true,
AutoplayMedia: boolPtr(false),
DisableViewingExt: ".bat",
DisableOnlyOfficeExt: ".x",
PreferEditorForMarkdown: true,
DebugOffice: true,
DefaultMediaPlayer: true,
},
Search: UserDefaultsSearch{
DisableOptions: true,
},
UI: UserDefaultsUI{
DarkMode: &dark,
ThemeColor: "var(--red)",
CustomTheme: "Night",
Locale: "de",
},
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
DownloadChunkSize: 2,
},
Account: UserDefaultsAccount{
Permissions: UserDefaultsAccountPermissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: &dlFalse,
},
LockPassword: true,
DisableSettings: true,
LoginMethod: "password",
DisableUpdateNotifications: true,
},
},
}
@@ -115,7 +130,7 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
DisablePreviewExt: ".exe",
DisableViewingExt: ".bat",
DisableUpdateNotifications: true,
DisableOfficePreviewExt: "legacy",
DisableOfficePreviewExt: ".x", // deprecated: mapped to DisableOnlyOfficeExt
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
@@ -123,6 +138,7 @@ func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
PreferEditorForMarkdown: true,
ShowToolsInSidebar: true,
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
@@ -166,8 +182,12 @@ func TestApplyUserDefaults_setsLoginMethodWhenEmpty(t *testing.T) {
defer func() { Config = saved }()
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{LoginMethod: "jwt"},
Server: Server{},
UserDefaults: UserDefaults{
Account: UserDefaultsAccount{
LoginMethod: "jwt",
},
},
}
u := &users.User{Username: "x"}
ApplyUserDefaults(u)
@@ -181,8 +201,12 @@ func TestApplyUserDefaults_preservesLoginMethodWhenAlreadySet(t *testing.T) {
defer func() { Config = saved }()
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{LoginMethod: "password"},
Server: Server{},
UserDefaults: UserDefaults{
Account: UserDefaultsAccount{
LoginMethod: "password",
},
},
}
u := &users.User{Username: "x", LoginMethod: users.LoginMethodProxy}
ApplyUserDefaults(u)
+10
View File
@@ -61,6 +61,7 @@ type LoginMethods struct {
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
LdapAuth LdapConfig `json:"ldap" validate:"omitempty"`
JwtAuth JwtAuthConfig `json:"jwt" validate:"omitempty"`
PasskeyAuth PasskeyAuthConfig `json:"passkey" validate:"omitempty"`
}
type PasswordAuthConfig struct {
@@ -115,6 +116,15 @@ type JwtAuthConfig struct {
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
+371 -35
View File
@@ -39,6 +39,8 @@ func Initialize(configFile string) {
setupLogging()
// setup logging first to ensure we log any errors
setupEnv()
// Migrate deprecated UserDefaults fields to new organized structure
migrateUserDefaults()
err = ValidateConfig(Config)
if err != nil {
errmsg := "The provided config file failed validation. "
@@ -466,13 +468,17 @@ func setupAuth(generate bool) {
}
logger.Info("JWT Auth configured successfully")
}
if Config.Auth.Methods.PasskeyAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "passkey")
logger.Info("Passkey Auth configured successfully")
}
// use password auth as default if no auth methods are set
if len(Config.Auth.AuthMethods) == 0 {
Config.Auth.Methods.PasswordAuth.Enabled = true
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
Config.UserDefaults.LoginMethod = Config.Auth.AuthMethods[0]
Config.UserDefaults.Account.LoginMethod = Config.Auth.AuthMethods[0]
}
@@ -484,31 +490,324 @@ func setupLogging() {
},
}
}
for _, logConfig := range Config.Server.Logging {
for i := range Config.Server.Logging {
cfg := &Config.Server.Logging[i]
// Enable debug logging automatically in dev mode
levels := logConfig.Levels
levels := cfg.Levels
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
levels = "info|warning|error|debug"
}
logConfig := logger.JsonConfig{
Levels: levels,
ApiLevels: logConfig.ApiLevels,
Output: logConfig.Output,
Utc: logConfig.Utc,
NoColors: logConfig.NoColors,
Json: logConfig.Json,
Structured: false,
pattern := cfg.ApiFilter
if pattern == "" {
pattern = "^/health|^/favicon.ico|^/static|^/public/static"
}
err := logger.EnableCompatibilityMode(logConfig)
jsonCfg := logger.JsonConfig{
Levels: levels,
ApiLevels: cfg.ApiLevels,
Output: cfg.Output,
Utc: cfg.Utc,
NoColors: cfg.NoColors,
Json: cfg.Json,
Structured: false,
ApiPathExclude: pattern,
}
err := logger.EnableCompatibilityMode(jsonCfg)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
}
}
// migrateUserDefaults migrates deprecated UserDefaults fields to the new organized structure.
// This function maintains backwards compatibility by checking if new fields are unset and copying
// values from old deprecated fields if found. Logs warnings when old fields are migrated.
func migrateUserDefaults() {
ud := &Config.UserDefaults
hasOldFields := false
// Helper function to check if a bool pointer is unset (nil)
isUnsetBoolPtr := func(v *bool) bool {
return v == nil
}
// Helper function to check if a string is unset (empty)
isUnsetString := func(v string) bool {
return v == ""
}
// Helper function to check if an int is unset (zero)
isUnsetInt := func(v int) bool {
return v == 0
}
// Migrate Preview-related deprecated fields
if !ud.Sidebar.DisableHideOnPreview && ud.Preview.DisableHideSidebar {
ud.Sidebar.DisableHideOnPreview = ud.Preview.DisableHideSidebar
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'preview.disableHideSidebar' to 'sidebar.disableHideOnPreview'")
}
if !ud.FileViewer.DefaultMediaPlayer && ud.Preview.DefaultMediaPlayer {
ud.FileViewer.DefaultMediaPlayer = ud.Preview.DefaultMediaPlayer
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'preview.defaultMediaPlayer' to 'fileViewer.defaultMediaPlayer'")
}
if isUnsetBoolPtr(ud.FileViewer.AutoplayMedia) && ud.Preview.AutoplayMedia {
autoplay := ud.Preview.AutoplayMedia
ud.FileViewer.AutoplayMedia = &autoplay
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'preview.autoplayMedia' to 'fileViewer.autoplayMedia'")
}
if isUnsetString(ud.Preview.DisablePreviewExt) && !isUnsetString(ud.DisablePreviewExt) {
ud.Preview.DisablePreviewExt = ud.DisablePreviewExt
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disablePreviewExt' to 'preview.disablePreviewExt'")
}
// Migrate Sidebar fields
if !ud.Sidebar.DisableQuickToggles && ud.DisableQuickToggles {
ud.Sidebar.DisableQuickToggles = ud.DisableQuickToggles
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableQuickToggles' to 'sidebar.disableQuickToggles'")
}
if !ud.Sidebar.HideFileActions && ud.HideSidebarFileActions {
ud.Sidebar.HideFileActions = ud.HideSidebarFileActions
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'hideSidebarFileActions' to 'sidebar.hideFileActions'")
}
if !ud.Sidebar.Sticky && ud.StickySidebar {
ud.Sidebar.Sticky = ud.StickySidebar
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'stickySidebar' to 'sidebar.sticky'")
}
if isUnsetString(ud.Listing.ViewMode) && !isUnsetString(ud.ViewMode) {
ud.Listing.ViewMode = ud.ViewMode
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'viewMode' to 'sidebar.viewMode'")
}
if isUnsetInt(ud.Listing.GallerySize) && !isUnsetInt(ud.GallerySize) {
ud.Listing.GallerySize = ud.GallerySize
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'gallerySize' to 'sidebar.gallerySize'")
}
if !ud.Sidebar.HideFiles && ud.HideFilesInTree {
ud.Sidebar.HideFiles = ud.HideFilesInTree
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'hideFilesInTree' to 'sidebar.hideFiles'")
}
if isUnsetBoolPtr(ud.Sidebar.ShowTools) && ud.ShowToolsInSidebar != nil {
ud.Sidebar.ShowTools = ud.ShowToolsInSidebar
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'showToolsInSidebar' to 'sidebar.showTools'")
}
// Migrate Listing fields
if !ud.Listing.DeleteWithoutConfirming && ud.DeleteWithoutConfirming {
ud.Listing.DeleteWithoutConfirming = ud.DeleteWithoutConfirming
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'deleteWithoutConfirming' to 'listing.deleteWithoutConfirming'")
}
if !ud.Listing.DateFormat && ud.DateFormat {
ud.Listing.DateFormat = ud.DateFormat
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'dateFormat' to 'listing.dateFormat'")
}
if !ud.Listing.ShowHidden && ud.ShowHidden {
ud.Listing.ShowHidden = ud.ShowHidden
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'showHidden' to 'listing.showHidden'")
}
if !ud.Listing.QuickDownload && ud.QuickDownload {
ud.Listing.QuickDownload = ud.QuickDownload
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'quickDownload' to 'listing.quickDownload'")
}
if !ud.Listing.ShowSelectMultiple && ud.ShowSelectMultiple {
ud.Listing.ShowSelectMultiple = ud.ShowSelectMultiple
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'showSelectMultiple' to 'listing.showSelectMultiple'")
}
if !ud.Listing.SingleClick && ud.SingleClick {
ud.Listing.SingleClick = ud.SingleClick
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'singleClick' to 'listing.singleClick'")
}
if isUnsetString(ud.Listing.HideFileExt) && !isUnsetString(ud.HideFileExt) {
ud.Listing.HideFileExt = ud.HideFileExt
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'hideFileExt' to 'listing.hideFileExt'")
}
if !ud.Listing.ShowCopyPath && ud.ShowCopyPath {
ud.Listing.ShowCopyPath = ud.ShowCopyPath
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'showCopyPath' to 'listing.showCopyPath'")
}
if !ud.Listing.DeleteAfterArchive && ud.DeleteAfterArchive {
ud.Listing.DeleteAfterArchive = ud.DeleteAfterArchive
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'deleteAfterArchive' to 'listing.deleteAfterArchive'")
}
// Migrate FileViewer fields
if !ud.FileViewer.EditorQuickSave && ud.EditorQuickSave {
ud.FileViewer.EditorQuickSave = ud.EditorQuickSave
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'editorQuickSave' to 'fileViewer.editorQuickSave'")
}
if isUnsetString(ud.FileViewer.DisableViewingExt) && !isUnsetString(ud.DisableViewingExt) {
ud.FileViewer.DisableViewingExt = ud.DisableViewingExt
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableViewingExt' to 'fileViewer.disableViewingExt'")
}
if isUnsetString(ud.FileViewer.DisableOnlyOfficeExt) && !isUnsetString(ud.DisableOnlyOfficeExt) {
ud.FileViewer.DisableOnlyOfficeExt = ud.DisableOnlyOfficeExt
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableOnlyOfficeExt' to 'fileViewer.disableOnlyOfficeExt'")
}
if !ud.FileViewer.PreferEditorForMarkdown && ud.PreferEditorForMarkdown {
ud.FileViewer.PreferEditorForMarkdown = ud.PreferEditorForMarkdown
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'preferEditorForMarkdown' to 'fileViewer.preferEditorForMarkdown'")
}
if !ud.FileViewer.DebugOffice && ud.DebugOffice {
ud.FileViewer.DebugOffice = ud.DebugOffice
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'debugOffice' to 'fileViewer.debugOffice'")
}
// Migrate Search fields
if !ud.Search.DisableOptions && ud.DisableSearchOptions {
ud.Search.DisableOptions = ud.DisableSearchOptions
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableSearchOptions' to 'search.disableOptions'")
}
// Migrate UI fields
if isUnsetBoolPtr(ud.UI.DarkMode) && ud.DarkMode != nil {
ud.UI.DarkMode = ud.DarkMode
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'darkMode' to 'ui.darkMode'")
}
if isUnsetString(ud.UI.ThemeColor) && !isUnsetString(ud.ThemeColor) {
ud.UI.ThemeColor = ud.ThemeColor
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'themeColor' to 'ui.themeColor'")
}
if isUnsetString(ud.UI.CustomTheme) && !isUnsetString(ud.CustomTheme) {
ud.UI.CustomTheme = ud.CustomTheme
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'customTheme' to 'ui.customTheme'")
}
if isUnsetString(ud.UI.Locale) && !isUnsetString(ud.Locale) {
ud.UI.Locale = ud.Locale
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'locale' to 'ui.locale'")
}
// Migrate Account fields
if !ud.Account.LockPassword && ud.LockPassword {
ud.Account.LockPassword = ud.LockPassword
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'lockPassword' to 'account.lockPassword'")
}
if !ud.Account.DisableSettings && ud.DisableSettings {
ud.Account.DisableSettings = ud.DisableSettings
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableSettings' to 'account.disableSettings'")
}
if isUnsetString(ud.Account.LoginMethod) && !isUnsetString(ud.LoginMethod) {
ud.Account.LoginMethod = ud.LoginMethod
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'loginMethod' to 'account.loginMethod'")
}
if !ud.Account.DisableUpdateNotifications && ud.DisableUpdateNotifications {
ud.Account.DisableUpdateNotifications = ud.DisableUpdateNotifications
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'disableUpdateNotifications' to 'account.disableUpdateNotifications'")
}
// Migrate Account Permissions fields
if !ud.Account.Permissions.Api && ud.Permissions.Api {
ud.Account.Permissions.Api = ud.Permissions.Api
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.api' to 'account.permissions.api'")
}
if !ud.Account.Permissions.Admin && ud.Permissions.Admin {
ud.Account.Permissions.Admin = ud.Permissions.Admin
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.admin' to 'account.permissions.admin'")
}
if !ud.Account.Permissions.Modify && ud.Permissions.Modify {
ud.Account.Permissions.Modify = ud.Permissions.Modify
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.modify' to 'account.permissions.modify'")
}
if !ud.Account.Permissions.Share && ud.Permissions.Share {
ud.Account.Permissions.Share = ud.Permissions.Share
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.share' to 'account.permissions.share'")
}
if !ud.Account.Permissions.Realtime && ud.Permissions.Realtime {
ud.Account.Permissions.Realtime = ud.Permissions.Realtime
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.realtime' to 'account.permissions.realtime'")
}
if !ud.Account.Permissions.Delete && ud.Permissions.Delete {
ud.Account.Permissions.Delete = ud.Permissions.Delete
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.delete' to 'account.permissions.delete'")
}
if !ud.Account.Permissions.Create && ud.Permissions.Create {
ud.Account.Permissions.Create = ud.Permissions.Create
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.create' to 'account.permissions.create'")
}
if isUnsetBoolPtr(ud.Account.Permissions.Download) && ud.Permissions.Download != nil {
ud.Account.Permissions.Download = ud.Permissions.Download
hasOldFields = true
logger.Warning("userDefaults: migrating deprecated field 'permissions.download' to 'account.permissions.download'")
}
if hasOldFields {
logger.Warning("userDefaults: Please update your configuration to use the new organized structure. See documentation for details.")
}
}
func loadConfigWithDefaults(configFile string, generate bool) error {
Config = setDefaults(generate)
Config = SetDefaults(generate)
// Check if config file exists
if _, err := os.Stat(configFile); err != nil {
@@ -661,7 +960,7 @@ func loadEnvConfig() {
}
}
func setDefaults(generate bool) Settings {
func SetDefaults(generate bool) Settings {
// get number of CPUs available
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
cpus := runtime.NumCPU()
@@ -712,25 +1011,27 @@ func setDefaults(generate bool) Settings {
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
DeleteAfterArchive: true,
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
HideFilesInTree: false,
PreferEditorForMarkdown: false,
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: UserDefaultsPermissions{
Modify: false,
Share: false,
Admin: false,
Api: false,
Download: boolPtr(true), // defaults to true
// New organized structure
Sidebar: UserDefaultsSidebar{
DisableQuickToggles: false,
HideFileActions: false,
DisableHideOnPreview: false,
Sticky: true,
HideFiles: false,
ShowTools: boolPtr(true),
},
Listing: UserDefaultsListing{
DeleteWithoutConfirming: false,
DateFormat: false,
ShowHidden: false,
QuickDownload: false,
ShowSelectMultiple: false,
SingleClick: false,
HideFileExt: "",
ShowCopyPath: false,
DeleteAfterArchive: true,
ViewMode: "normal",
GallerySize: 3,
},
Preview: UserDefaultsPreview{
Image: boolPtr(true),
@@ -739,15 +1040,50 @@ func setDefaults(generate bool) Settings {
MotionVideoPreview: boolPtr(true),
Office: boolPtr(true),
PopUp: boolPtr(true),
AutoplayMedia: boolPtr(true),
DisablePreviewExt: "",
HighQuality: boolPtr(true),
Folder: boolPtr(true),
Models: boolPtr(true),
},
FileViewer: UserDefaultsFileViewer{
EditorQuickSave: false,
AutoplayMedia: boolPtr(true),
DisableViewingExt: "",
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
PreferEditorForMarkdown: false,
DebugOffice: false,
DefaultMediaPlayer: false,
},
Search: UserDefaultsSearch{
DisableOptions: false,
},
UI: UserDefaultsUI{
DarkMode: boolPtr(true),
ThemeColor: "var(--blue)",
CustomTheme: "",
Locale: "en",
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
UploadChunkSize: 10, // 10MB
DownloadChunkSize: 0, // 0MB, default to no chunking
},
Account: UserDefaultsAccount{
Permissions: UserDefaultsAccountPermissions{
Api: false,
Admin: false,
Modify: false,
Share: false,
Realtime: false,
Delete: false,
Create: false,
Download: boolPtr(true), // defaults to true
},
LockPassword: false,
DisableSettings: false,
LoginMethod: "",
DisableUpdateNotifications: false,
},
},
}
// Initialize ImagePreview map with defaults
+4 -4
View File
@@ -35,7 +35,7 @@ func Test_setDefaults(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setDefaults(true); !reflect.DeepEqual(got, tt.want) {
if got := SetDefaults(true); !reflect.DeepEqual(got, tt.want) {
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
}
})
@@ -54,7 +54,7 @@ func TestConfigLoadChanged(t *testing.T) {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := setDefaults(true)
defaultConfig := SetDefaults(true)
err = loadConfigWithDefaults(configFile, true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
@@ -77,7 +77,7 @@ func TestConfigLoadEnvVars(t *testing.T) {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := setDefaults(true)
defaultConfig := SetDefaults(true)
expectedKey := "MYKEY"
// mock environment variables
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
@@ -106,7 +106,7 @@ func TestConfigLoadSpecificValues(t *testing.T) {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := setDefaults(true)
defaultConfig := SetDefaults(true)
err = loadConfigWithDefaults(configFile, true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
+12 -12
View File
@@ -286,7 +286,7 @@ func buildPathMapRecursive(structType reflect.Type, structValue reflect.Value, c
fieldType := field.Type
// Handle pointers
if fieldType.Kind() == reflect.Ptr {
if fieldType.Kind() == reflect.Pointer {
if fieldValue.IsNil() {
// Skip nil pointers
continue
@@ -479,7 +479,7 @@ func CollectCommentsFromEmbeddedYaml(yamlContent string) (CommentsMap, SecretFie
// parseDefaultsFromEmbeddedYaml creates a default config by parsing the embedded YAML template
func parseDefaultsFromEmbeddedYaml(embeddedYaml string) (*Settings, error) {
// Start with baseline defaults from setDefaults to ensure all fields are initialized
defaultConfig := setDefaults(true)
defaultConfig := SetDefaults(true)
defaultConfig.Server.Sources = []*Source{{Path: "."}}
// Parse the embedded YAML to overlay the documented defaults
@@ -516,7 +516,7 @@ func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
// buildNodeWithDefaults constructs a yaml.Node for any Go value, skipping fields that match defaults, redacting secrets, and filtering deprecated fields
func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.Value, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
// Dereference pointers
if v.Kind() == reflect.Ptr {
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
}
@@ -790,7 +790,7 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
elemType := v.Type().Elem()
// Check if element is a struct or pointer to struct
isStructSlice := elemType.Kind() == reflect.Struct ||
(elemType.Kind() == reflect.Ptr && elemType.Elem().Kind() == reflect.Struct)
(elemType.Kind() == reflect.Pointer && elemType.Elem().Kind() == reflect.Struct)
if !isStructSlice {
seq.Style = yaml.FlowStyle
}
@@ -995,7 +995,7 @@ func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFul
// identifySecretFieldsByReflection identifies secret fields by known field names
func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets SecretFieldsMap) {
if v.Kind() == reflect.Ptr {
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
@@ -1030,9 +1030,9 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
// Recursively check nested structs
fieldValue := v.Field(i)
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
nestedTypeName := field.Type.Name()
if field.Type.Kind() == reflect.Ptr {
if field.Type.Kind() == reflect.Pointer {
nestedTypeName = field.Type.Elem().Name()
}
identifySecretFieldsByReflection(fieldValue, nestedTypeName, secrets)
@@ -1042,7 +1042,7 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
// identifyDeprecatedFieldsByReflection identifies deprecated fields by known field names
func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, deprecated DeprecatedFieldsMap) {
if v.Kind() == reflect.Ptr {
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
@@ -1072,9 +1072,9 @@ func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, depr
// Recursively check nested structs
fieldValue := v.Field(i)
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
nestedTypeName := field.Type.Name()
if field.Type.Kind() == reflect.Ptr {
if field.Type.Kind() == reflect.Pointer {
nestedTypeName = field.Type.Elem().Name()
}
identifyDeprecatedFieldsByReflection(fieldValue, nestedTypeName, deprecated)
@@ -1108,7 +1108,7 @@ func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, e
}
if err != nil || readErr != nil {
// Fallback to setDefaults
defaultConfigValue := setDefaults(true)
defaultConfigValue := SetDefaults(true)
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
defaultConfig = &defaultConfigValue
}
@@ -1201,7 +1201,7 @@ func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull
}
if err != nil || readErr != nil {
// Fallback to setDefaults
defaultConfigValue := setDefaults(true)
defaultConfigValue := SetDefaults(true)
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
defaultConfig = &defaultConfigValue
}
+221
View File
@@ -0,0 +1,221 @@
package settings
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// TestMigrateUserDefaults_OldToNew verifies that deprecated fields are correctly migrated to new fields
func TestMigrateUserDefaults_OldToNew(t *testing.T) {
// Setup: Create UserDefaults with old fields set
Config.UserDefaults = UserDefaults{
// Old deprecated fields
DarkMode: boolPtr(true),
DisableQuickToggles: true,
HideSidebarFileActions: true,
StickySidebar: true,
SingleClick: true,
HideFilesInTree: true,
ShowToolsInSidebar: boolPtr(false),
DeleteWithoutConfirming: true,
DateFormat: true,
ShowHidden: true,
QuickDownload: true,
ShowSelectMultiple: true,
HideFileExt: ".tmp",
ShowCopyPath: true,
DeleteAfterArchive: true,
DisablePreviewExt: ".exe",
EditorQuickSave: true,
DisableViewingExt: ".bin",
DisableOnlyOfficeExt: ".md",
PreferEditorForMarkdown: true,
DebugOffice: true,
DisableSearchOptions: true,
ThemeColor: "var(--red)",
CustomTheme: "dark",
Locale: "de",
LockPassword: true,
DisableSettings: true,
LoginMethod: "oidc",
DisableUpdateNotifications: true,
Permissions: UserDefaultsPermissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: boolPtr(false),
},
Preview: UserDefaultsPreview{
Image: boolPtr(true),
Video: boolPtr(true),
Audio: boolPtr(true),
MotionVideoPreview: boolPtr(true),
Office: boolPtr(true),
PopUp: boolPtr(true),
HighQuality: boolPtr(true),
Folder: boolPtr(true),
Models: boolPtr(true),
// Deprecated fields that should be migrated
DisableHideSidebar: true,
AutoplayMedia: true,
DefaultMediaPlayer: true,
},
FileLoading: users.FileLoading{
MaxConcurrent: 5,
UploadChunkSize: 20,
},
}
// Execute: Run migration
migrateUserDefaults()
// Verify: Check that new fields are populated from old fields
ud := &Config.UserDefaults
// Sidebar fields
if !ud.Sidebar.DisableQuickToggles {
t.Error("sidebar.disableQuickToggles should be true")
}
if !ud.Sidebar.HideFileActions {
t.Error("sidebar.hideFileActions should be true")
}
if !ud.Sidebar.Sticky {
t.Error("sidebar.sticky should be true")
}
if !ud.Sidebar.HideFiles {
t.Error("sidebar.hideFiles should be true")
}
if ud.Sidebar.ShowTools == nil || *ud.Sidebar.ShowTools != false {
t.Error("sidebar.showTools should be false")
}
if !ud.Sidebar.DisableHideOnPreview {
t.Error("sidebar.disableHideOnPreview should be true")
}
// Listing fields
if !ud.Listing.DeleteWithoutConfirming {
t.Error("listing.deleteWithoutConfirming should be true")
}
if !ud.Listing.DateFormat {
t.Error("listing.dateFormat should be true")
}
if !ud.Listing.ShowHidden {
t.Error("listing.showHidden should be true")
}
if !ud.Listing.QuickDownload {
t.Error("listing.quickDownload should be true")
}
if !ud.Listing.ShowSelectMultiple {
t.Error("listing.showSelectMultiple should be true")
}
if !ud.Listing.SingleClick {
t.Error("listing.singleClick should be true")
}
if ud.Listing.HideFileExt != ".tmp" {
t.Errorf("listing.hideFileExt should be '.tmp', got '%s'", ud.Listing.HideFileExt)
}
if !ud.Listing.ShowCopyPath {
t.Error("listing.showCopyPath should be true")
}
if !ud.Listing.DeleteAfterArchive {
t.Error("listing.deleteAfterArchive should be true")
}
// Preview fields
if ud.Preview.Image == nil || *ud.Preview.Image != true {
t.Error("preview.image should be true")
}
if ud.Preview.Video == nil || *ud.Preview.Video != true {
t.Error("preview.video should be true")
}
if ud.Preview.Audio == nil || *ud.Preview.Audio != true {
t.Error("preview.audio should be true")
}
if ud.Preview.DisablePreviewExt != ".exe" {
t.Errorf("preview.disablePreviewExt should be '.exe', got '%s'", ud.Preview.DisablePreviewExt)
}
// FileViewer fields
if !ud.FileViewer.EditorQuickSave {
t.Error("fileViewer.editorQuickSave should be true")
}
if ud.FileViewer.AutoplayMedia == nil || *ud.FileViewer.AutoplayMedia != true {
t.Error("fileViewer.autoplayMedia should be true")
}
if ud.FileViewer.DisableViewingExt != ".bin" {
t.Errorf("fileViewer.disableViewingExt should be '.bin', got '%s'", ud.FileViewer.DisableViewingExt)
}
if ud.FileViewer.DisableOnlyOfficeExt != ".md" {
t.Errorf("fileViewer.disableOnlyOfficeExt should be '.md', got '%s'", ud.FileViewer.DisableOnlyOfficeExt)
}
if !ud.FileViewer.PreferEditorForMarkdown {
t.Error("fileViewer.preferEditorForMarkdown should be true")
}
if !ud.FileViewer.DebugOffice {
t.Error("fileViewer.debugOffice should be true")
}
// Search fields
if !ud.Search.DisableOptions {
t.Error("search.disableOptions should be true")
}
// UI fields
if ud.UI.DarkMode == nil || *ud.UI.DarkMode != true {
t.Error("ui.darkMode should be true")
}
if ud.UI.ThemeColor != "var(--red)" {
t.Errorf("ui.themeColor should be 'var(--red)', got '%s'", ud.UI.ThemeColor)
}
if ud.UI.CustomTheme != "dark" {
t.Errorf("ui.customTheme should be 'dark', got '%s'", ud.UI.CustomTheme)
}
if ud.UI.Locale != "de" {
t.Errorf("ui.locale should be 'de', got '%s'", ud.UI.Locale)
}
// Account fields
if !ud.Account.LockPassword {
t.Error("account.lockPassword should be true")
}
if !ud.Account.DisableSettings {
t.Error("account.disableSettings should be true")
}
if ud.Account.LoginMethod != "oidc" {
t.Errorf("account.loginMethod should be 'oidc', got '%s'", ud.Account.LoginMethod)
}
if !ud.Account.DisableUpdateNotifications {
t.Error("account.disableUpdateNotifications should be true")
}
// Account Permissions
if !ud.Account.Permissions.Api {
t.Error("account.permissions.api should be true")
}
if !ud.Account.Permissions.Admin {
t.Error("account.permissions.admin should be true")
}
if !ud.Account.Permissions.Modify {
t.Error("account.permissions.modify should be true")
}
if !ud.Account.Permissions.Share {
t.Error("account.permissions.share should be true")
}
if !ud.Account.Permissions.Realtime {
t.Error("account.permissions.realtime should be true")
}
if !ud.Account.Permissions.Delete {
t.Error("account.permissions.delete should be true")
}
if !ud.Account.Permissions.Create {
t.Error("account.permissions.create should be true")
}
if ud.Account.Permissions.Download == nil || *ud.Account.Permissions.Download != false {
t.Error("account.permissions.download should be false")
}
}
+70 -47
View File
@@ -21,7 +21,7 @@ func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
}
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
func ConvertPermissionsToUsers(p UserDefaultsPermissions) users.Permissions {
func ConvertPermissionsToUsers(p UserDefaultsAccountPermissions) users.Permissions {
return users.Permissions{
Api: p.Api,
Admin: p.Admin,
@@ -57,10 +57,14 @@ func GetSettingsConfig(nameType string, Value string) string {
func AdminPerms() users.Permissions {
return users.Permissions{
Modify: true,
Share: true,
Admin: true,
Api: true,
Modify: true,
Share: true,
Admin: true,
Api: true,
Download: true,
Delete: true,
Create: true,
Realtime: false,
}
}
@@ -70,60 +74,77 @@ func AdminPerms() users.Permissions {
func ApplyUserDefaults(u *users.User) {
d := Config.UserDefaults
u.DisableSettings = d.DisableSettings
u.LockPassword = d.LockPassword
// Account settings
u.DisableSettings = d.Account.DisableSettings
u.LockPassword = d.Account.LockPassword
u.DisableUpdateNotifications = d.Account.DisableUpdateNotifications
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
// 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.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)
// 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.Preview.DisableHideSidebar = d.Preview.DisableHideSidebar
// Preview settings
u.Preview.DisableHideSidebar = d.Sidebar.DisableHideOnPreview
u.Preview.Image = boolValueOrDefault(d.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(d.Preview.Video, true)
u.Preview.Audio = boolValueOrDefault(d.Preview.Audio, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(d.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(d.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(d.Preview.PopUp, true)
u.Preview.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
if u.LoginMethod == "" && d.LoginMethod != "" {
u.LoginMethod = users.LoginMethod(d.LoginMethod)
// 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
// 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 && u.Username != "anonymous" {
@@ -143,4 +164,6 @@ func ApplyUserDefaults(u *users.User) {
}
}
}
u.Version = users.CurrentUserMigrationVersion
}
+4 -4
View File
@@ -37,12 +37,12 @@ func (s *Storage) Save(set *Settings) error {
return errors.ErrEmptyKey
}
if set.UserDefaults.Locale == "" {
set.UserDefaults.Locale = "en"
if set.UserDefaults.UI.Locale == "" {
set.UserDefaults.UI.Locale = "en"
}
if set.UserDefaults.ViewMode == "" {
set.UserDefaults.ViewMode = "normal"
if set.UserDefaults.Listing.ViewMode == "" {
set.UserDefaults.Listing.ViewMode = "normal"
}
err := s.back.Save(set)
+138 -52
View File
@@ -191,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
Utc bool `json:"utc" yaml:"utc"` // use UTC time in the output instead of local time.
ApiFilter string `json:"apiFilter" yaml:"apiFilter"` // regex filter that excludes matching full api paths from being logged. (eg. '/user\?id\=self') Defaults to '^/health|^/favicon.ico|^/static|^/public/static'
}
type Source struct {
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
Name string `json:"name"` // display name
@@ -307,24 +307,99 @@ type ExternalLink struct {
Url string `json:"url" validate:"required"` // the url to link to
}
// UserDefaultsPreview holds preview settings with pointer types for defaults
type UserDefaultsPreview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
HighQuality *bool `json:"highQuality"` // deprecated: always true in v1.3.0+
Image *bool `json:"image"` // show thumbnails for image files
Video *bool `json:"video"` // show thumbnails for video files
Audio *bool `json:"audio"` // show thumbnails for audio files
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office *bool `json:"office"` // show thumbnails for office files
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
Models *bool `json:"models"` // show live thumbnails for 3D models files
}
// UserDefaultsPermissions holds permission settings with pointer types for defaults
type UserDefaultsPermissions struct {
Api bool `json:"api"` // deprecated: use account.permissions.api instead. allow api access
Admin bool `json:"admin"` // deprecated: use account.permissions.admin instead. allow admin access
Modify bool `json:"modify"` // deprecated: use account.permissions.modify instead. allow modifying files
Share bool `json:"share"` // deprecated: use account.permissions.share instead. allow sharing files
Realtime bool `json:"realtime"` // deprecated: use account.permissions.realtime instead. allow realtime updates
Delete bool `json:"delete"` // deprecated: use account.permissions.delete instead. allow deleting files
Create bool `json:"create"` // deprecated: use account.permissions.create instead. allow creating or uploading files
Download *bool `json:"download"` // deprecated: use account.permissions.download instead. allow downloading files
}
// New organized structures for UserDefaults
// UserDefaultsSidebar holds sidebar-related settings
type UserDefaultsSidebar struct {
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
HideFileActions bool `json:"hideFileActions"` // hide the file actions in the sidebar
DisableHideOnPreview bool `json:"disableHideOnPreview"` // keep sidebar open when previewing files (was preview.disableHideSidebar)
Sticky bool `json:"sticky"` // keep sidebar open when navigating
HideFiles bool `json:"hideFiles"` // hide files in the sidebar tree navigation, when true, will show only directories
ShowTools *bool `json:"showTools"` // show sidebar links with category "tool"; default is true
}
// UserDefaultsListing holds file listing display settings
type UserDefaultsListing struct {
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI
ShowCopyPath bool `json:"showCopyPath"` // show copy path button in the context menu
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
}
// UserDefaultsPreview holds preview-related settings
type UserDefaultsPreview struct {
Image *bool `json:"image"` // show thumbnails for image files
Video *bool `json:"video"` // show thumbnails for video files
Audio *bool `json:"audio"` // show thumbnails for audio files
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office *bool `json:"office"` // show thumbnails for office files
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
DisablePreviewExt string `json:"disablePreviewExt"` // comma separated list of file extensions to disable preview for
HighQuality *bool `json:"highQuality"` // high quality preview thumbnails
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
Models *bool `json:"models"` // show live thumbnails for 3D models files
// deprecated fields
DisableHideSidebar bool `json:"disableHideSidebar"` // deprecated: use sidebar.disableHideOnPreview instead. disable the hide sidebar preview for previews and editors
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // deprecated: use fileViewer.defaultMediaPlayer instead. disable the styled feature-rich media player for browser default
AutoplayMedia bool `json:"autoplayMedia"` // deprecated: use fileViewer.autoplayMedia instead. autoplay media files in preview
}
// UserDefaultsFileViewer holds file viewer/editor settings
type UserDefaultsFileViewer struct {
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
DisableViewingExt string `json:"disableViewingExt"` // comma separated list of file extensions to disable viewing for
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
}
// UserDefaultsSearch holds search-related settings
type UserDefaultsSearch struct {
DisableOptions bool `json:"disableOptions"` // disable the search options in the search bar
}
// UserDefaultsUI holds UI/appearance settings
type UserDefaultsUI struct {
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config
Locale string `json:"locale"` // language to use: eg. de, en, or fr
}
// UserDefaultsAccount holds account/security settings
type UserDefaultsAccount struct {
Permissions UserDefaultsAccountPermissions `json:"permissions"`
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
DisableSettings bool `json:"disableSettings"` // disable the user from viewing the settings page
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
}
// UserDefaultsAccountPermissions holds permission settings
type UserDefaultsAccountPermissions struct {
Api bool `json:"api"` // allow api access
Admin bool `json:"admin"` // allow admin access
Modify bool `json:"modify"` // allow modifying files
@@ -335,40 +410,51 @@ type UserDefaultsPermissions struct {
Download *bool `json:"download"` // allow downloading files
}
// UserDefaults is a type that holds the default values
// for some fields on User.
// UserDefaults is a type that holds the default values for some fields on User.
type UserDefaults struct {
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
Preview UserDefaultsPreview `json:"preview"`
// New organized structure
Sidebar UserDefaultsSidebar `json:"sidebar,omitempty"`
Listing UserDefaultsListing `json:"listing,omitempty"`
Preview UserDefaultsPreview `json:"preview,omitempty"`
FileViewer UserDefaultsFileViewer `json:"fileViewer,omitempty"`
Search UserDefaultsSearch `json:"search,omitempty"`
UI UserDefaultsUI `json:"ui,omitempty"`
FileLoading users.FileLoading `json:"fileLoading,omitempty"`
Account UserDefaultsAccount `json:"account,omitempty"`
// Deprecated fields - kept for backwards compatibility
EditorQuickSave bool `json:"editorQuickSave,omitempty"` // deprecated: use fileViewer.editorQuickSave instead
HideSidebarFileActions bool `json:"hideSidebarFileActions,omitempty"` // deprecated: use sidebar.hideSidebarFileActions instead
DisableQuickToggles bool `json:"disableQuickToggles,omitempty"` // deprecated: use sidebar.disableQuickToggles instead
DisableSearchOptions bool `json:"disableSearchOptions,omitempty"` // deprecated: use search.disableOptions instead
StickySidebar bool `json:"stickySidebar,omitempty"` // deprecated: use sidebar.stickySidebar instead
HideFilesInTree bool `json:"hideFilesInTree,omitempty"` // deprecated: use sidebar.hideFilesInTree instead
DarkMode *bool `json:"darkMode,omitempty"` // deprecated: use sidebar.darkMode instead
Locale string `json:"locale,omitempty"` // deprecated: use ui.locale instead
ViewMode string `json:"viewMode,omitempty"` // deprecated: use sidebar.viewMode instead
SingleClick bool `json:"singleClick,omitempty"` // deprecated: use sidebar.singleClick instead
ShowHidden bool `json:"showHidden,omitempty"` // deprecated: use listing.showHidden instead
HideFileExt string `json:"hideFileExt,omitempty"` // deprecated: use listing.hideFileExt instead
DateFormat bool `json:"dateFormat,omitempty"` // deprecated: use listing.dateFormat instead
GallerySize int `json:"gallerySize,omitempty"` // deprecated: use sidebar.gallerySize instead
ThemeColor string `json:"themeColor,omitempty"` // deprecated: use ui.themeColor instead
QuickDownload bool `json:"quickDownload,omitempty"` // deprecated: use listing.quickDownload instead
DisablePreviewExt string `json:"disablePreviewExt,omitempty"` // deprecated: use preview.disablePreviewExt instead
DisableViewingExt string `json:"disableViewingExt,omitempty"` // deprecated: use fileViewer.disableViewingExt instead
LockPassword bool `json:"lockPassword,omitempty"` // deprecated: use account.lockPassword instead
DisableSettings bool `json:"disableSettings,omitempty"` // deprecated: use account.disableSettings instead
DefaultScopes []users.SourceScope `json:"-"`
Permissions UserDefaultsPermissions `json:"permissions"`
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer.
Permissions UserDefaultsPermissions `json:"permissions,omitempty"` // deprecated: use account.permissions instead
LoginMethod string `json:"loginMethod,omitempty"` // deprecated: use account.loginMethod instead
DisableUpdateNotifications bool `json:"disableUpdateNotifications,omitempty"` // deprecated: use account.disableUpdateNotifications instead
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming,omitempty"` // deprecated: use listing.deleteWithoutConfirming instead
DeleteAfterArchive bool `json:"deleteAfterArchive,omitempty"` // deprecated: use listing.deleteAfterArchive instead
DisableOfficePreviewExt string `json:"disableOfficePreviewExt,omitempty"` // deprecated: use disablePreviewExt instead
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt,omitempty"` // deprecated: use fileViewer.disableOnlyOfficeExt instead
CustomTheme string `json:"customTheme,omitempty"` // deprecated: use ui.customTheme instead
ShowSelectMultiple bool `json:"showSelectMultiple,omitempty"` // deprecated: use listing.showSelectMultiple instead
ShowToolsInSidebar *bool `json:"showToolsInSidebar,omitempty"` // deprecated: use sidebar.showToolsInSidebar instead
DebugOffice bool `json:"debugOffice,omitempty"` // deprecated: use fileViewer.debugOffice instead
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown,omitempty"` // deprecated: use fileViewer.preferEditorForMarkdown instead
ShowCopyPath bool `json:"showCopyPath,omitempty"` // deprecated: use listing.showCopyPath instead
}
+23
View File
@@ -57,6 +57,7 @@ type FileOptions struct {
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
ShowHidden bool // whether to show hidden files (true = show, false = hide)
HideFileExt string // Hide files based on extensions (eg: '.lrc' or '.srt')
FollowSymlinks bool // whether to follow symlinks
Only string // whether to only get files or folders
SkipExtendedAttrs bool // whether to skip extended attributes
@@ -86,3 +87,25 @@ func SanitizeUserPath(userPath string) (string, error) {
return clean, nil
}
// HideFileByExt will check if a filename matches any of the hidden extensions
func HideFileByExt(filename, hideExt string) bool {
if hideExt == "" {
return false
}
exts := strings.Fields(hideExt)
nameLower := strings.ToLower(filename)
for _, ext := range exts {
ext = strings.TrimSpace(ext)
if ext == "" {
continue
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
if strings.HasSuffix(nameLower, strings.ToLower(ext)) {
return true
}
}
return false
}
+5 -6
View File
@@ -1,11 +1,10 @@
server:
logging:
- levels: "info|warning|error"
sources:
- path: "/srv"
userDefaults:
preview:
image: true
popup: true
video: true
audio: true
motionVideoPreview: true
office: true
popup: true
folder: true
models: true
File renamed without changes.
+71 -38
View File
@@ -5,51 +5,84 @@ server:
- levels: "info|warning|error"
sources:
- path: "/srv"
auth:
key: "change-me-to-a-random-string"
totpSecret: "change-me-to-another-random-string"
methods:
password:
enabled: true
minLength: 5
signup: false
passkey:
enabled: false
userDefaults:
editorQuickSave: false # show quick save button in editor
hideSidebarFileActions: false # hide the file actions in the sidebar
disableQuickToggles: false # disable the quick toggles in the sidebar
disableSearchOptions: false # disable the search options in the search bar
stickySidebar: true # keep sidebar open when navigating
hideFilesInTree: false # hide files in the sidebar tree navigation, when true, will show only directories.
darkMode: true # should dark mode be enabled (defaults to true if not set)
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
singleClick: false # open directory on single click, also enables middle click to open in new tab
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
quickDownload: false # show icon to download in one click
disablePreviewExt: "" # space separated list of file extensions to disable preview for
disableViewingExt: "" # space separated list of file extensions to disable viewing for
lockPassword: false # disable the user from changing their password
disableSettings: false # disable the user from viewing the settings page
sidebar:
disableQuickToggles: false # disable the quick toggles in the sidebar
hideFileActions: false # hide the file actions in the sidebar
disableHideOnPreview: false # keep sidebar open when previewing files
sticky: true # keep sidebar open when navigating
hideFiles: false # hide files in the sidebar tree navigation, when true, will show only directories
showTools: true # show sidebar links of category "tool"; when false they stay in the list but are hidden in the UI
listing:
deleteWithoutConfirming: false # delete files without confirmation
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
quickDownload: false # show icon to download in one click
showSelectMultiple: false # show select multiple files on desktop
singleClick: false # open directory on single click, also enables middle click to open in new tab
hideFileExt: "" # space separated list of file extensions to hide in UI
showCopyPath: false # show copy path button in the context menu
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
preview:
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
image: true # show thumbnail preview image for image files (defaults to true if not set)
video: true # show thumbnail preview image for video files (defaults to true if not set)
audio: true # show thumbnail preview image for audio files (defaults to true if not set)
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
office: true # show thumbnail preview image for office files (defaults to true if not set)
popup: true # show larger popup preview when hovering over thumbnail (defaults to true if not set)
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
defaultMediaPlayer: false # disable html5 media player and use the default media player
disablePreviewExt: "" # space separated list of file extensions to disable preview for
highQuality: true # high quality preview thumbnails
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
permissions:
api: false # allow api access
admin: false # allow admin access
modify: false # allow modifying files
share: false # allow sharing files
realtime: false # allow realtime updates
delete: false # allow deleting files
create: false # allow creating or uploading files
download: true # allow downloading files (defaults to true if not set)
disableUpdateNotifications: false # disable update notifications banner for admin users
deleteWithoutConfirming: false # delete files without confirmation
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
customTheme: "" # Name of theme to use chosen from custom themes config.
showSelectMultiple: false # show select multiple files on desktop
debugOffice: false # debug onlyoffice editor
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
fileViewer:
editorQuickSave: false # show quick save button in editor
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
disableViewingExt: "" # space separated list of file extensions to disable viewing for
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
debugOffice: false # debug onlyoffice editor
defaultMediaPlayer: false # disable html5 media player and use the default media player
search:
disableOptions: false # disable the search options in the search bar
ui:
darkMode: true # should dark mode be enabled (defaults to true if not set)
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
customTheme: "" # Name of theme to use chosen from custom themes config
locale: "en" # language to use: eg. de, en, or fr
fileLoading:
maxConcurrentUpload: 10 # maximum number of concurrent uploads
uploadChunkSizeMb: 10 # upload chunk size in MB
downloadChunkSizeMb: 0 # download chunk size in MB (0 = no chunking)
account:
permissions:
api: false # allow api access
admin: false # allow admin access
modify: false # allow modifying files
share: false # allow sharing files
realtime: false # allow realtime updates
delete: false # allow deleting files
create: false # allow creating or uploading files
download: true # allow downloading files (defaults to true if not set)
lockPassword: false # disable the user from changing their password
disableSettings: false # disable the user from viewing the settings page
loginMethod: "password" # login method to use: eg. password, proxy, oidc
disableUpdateNotifications: false # disable update notifications banner for admin users
+1
View File
@@ -36,4 +36,5 @@ type PersistedScannerInfo struct {
QuickScanTime int `json:"quickScanTime"`
FullScanTime int `json:"fullScanTime"`
LastScanned time.Time `json:"lastScanned"`
FullScanCounter int `json:"fullScanCounter"` // 0-4: position in 1 full + 4 quick cycle before next executeScan
}
+1
View File
@@ -44,6 +44,7 @@ type CommonShare struct {
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)
SourceURL string `json:"sourceURL,omitempty"`
CanEditShare bool `json:"canEditShare,omitempty"`
+29 -2
View File
@@ -18,9 +18,27 @@ type SearchItem struct {
HasPreview bool
}
// appendNameGlobSQL adds AND (name GLOB ? OR ...) or AND (name GLOB ? AND ...) when joinAnd is true.
func appendNameGlobSQL(query string, args []interface{}, patterns []string, joinAnd bool) (string, []interface{}) {
if len(patterns) == 0 {
return query, args
}
parts := make([]string, len(patterns))
for i := range patterns {
parts[i] = "name GLOB ?"
args = append(args, patterns[i])
}
join := " OR "
if joinAnd {
join = " AND "
}
return query + " AND (" + strings.Join(parts, join) + ")", args
}
// SearchItems queries the database for items matching the search criteria for a single source.
// When nameGlobPatterns is non-empty (and largest is false), rows are restricted with SQLite name GLOB ... OR ....
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.Rows, error) {
func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd bool) (*sql.Rows, error) {
query := `
SELECT path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
@@ -35,6 +53,10 @@ func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.
args = append(args, scope+"*")
}
if !largest && len(nameGlobPatterns) > 0 {
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd)
}
if largest {
query += " ORDER BY size DESC"
}
@@ -43,8 +65,9 @@ func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.
}
// SearchItemsMultiSource queries the database for items matching the search criteria across multiple sources.
// When nameGlobPatterns is non-empty (and largest is false), restricts rows with SQLite name GLOB ... OR ....
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool) (*sql.Rows, error) {
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd bool) (*sql.Rows, error) {
if len(sources) == 0 {
return nil, fmt.Errorf("at least one source is required")
}
@@ -95,6 +118,10 @@ func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[str
query += " WHERE " + strings.Join(whereClauses, " AND ")
}
if !largest && len(nameGlobPatterns) > 0 {
query, args = appendNameGlobSQL(query, args, nameGlobPatterns, nameGlobPatternsAnd)
}
if largest {
query += " ORDER BY size DESC"
}
+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.Ptr {
if v.Kind() == reflect.Pointer {
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.Ptr {
if embeddedType.Kind() == reflect.Pointer {
embeddedType = embeddedType.Elem()
}
+3 -1
View File
@@ -92,9 +92,10 @@ func quickSetup(store *bolt.BoltStore) {
settings.Config.Auth.AdminPassword = "admin"
}
user.Password = settings.Config.Auth.AdminPassword
user.Permissions.Admin = true
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)
@@ -123,6 +124,7 @@ func CreateUser(userInfo users.User, permissions users.Permissions) error {
}
settings.ApplyUserDefaults(newUser)
newUser.Permissions = permissions
newUser.Version = users.CurrentUserMigrationVersion
logger.Debugf("Creating user: %v %v", userInfo.Username, userInfo.Scopes)
// create new home directories
err := userStore.Save(newUser, true, false)
+31 -33
View File
@@ -5,6 +5,23 @@ import (
"strings"
)
// ResolveSourceKey maps a string that is either:
// - the configured source filesystem path (canonical Bolt / in-memory form for
// SourceScope.Name and source-type SidebarLink.SourceName), or
// - the source display name (JSON form from clients).
//
// Path is checked first so absolute paths are unambiguous. Used on write paths
// (normalize to Path) and read paths (normalize API to display Name).
func ResolveSourceKey(key string) (SourceInfo, bool) {
if sourceConfig == nil || key == "" {
return SourceInfo{}, false
}
if info, ok := sourceConfig.GetSourceByPath(key); ok {
return info, true
}
return sourceConfig.GetSourceByName(key)
}
// GetSourceNames returns all source names the user has access to (assumes backend-style scopes)
func (u *User) GetSourceNames() []string {
if sourceConfig == nil {
@@ -24,10 +41,9 @@ func (u *User) GetSourceNames() []string {
return sources
}
// GetBackendScopes converts the user's scopes from frontend-style to backend-style
// GetBackendScopes normalizes scopes for Bolt: SourceScope.Name is always the source filesystem path.
// Incoming rows (API) may use display name or path; legacy rows may use either.
func (u *User) GetBackendScopes() ([]SourceScope, error) {
// Only convert scopes if they are not empty
// Empty scopes during update should remain empty (not filled with defaults)
if len(u.Scopes) == 0 {
return []SourceScope{}, nil
}
@@ -37,24 +53,8 @@ func (u *User) GetBackendScopes() ([]SourceScope, error) {
newScopes := []SourceScope{}
for _, scope := range u.Scopes {
// First check if its already a path name and keep it
source, ok := sourceConfig.GetSourceByPath(scope.Name)
if ok {
if scope.Scope == "" {
scope.Scope = source.DefaultUserScope
}
scope.Scope = normalizeScope(scope.Scope)
newScopes = append(newScopes, SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
continue
}
// Check if its the name of a source and convert it to a path
source, ok = sourceConfig.GetSourceByName(scope.Name)
source, ok := ResolveSourceKey(scope.Name)
if !ok {
// source might no longer be configured
continue
}
if scope.Scope == "" {
@@ -62,15 +62,15 @@ func (u *User) GetBackendScopes() ([]SourceScope, error) {
}
scope.Scope = normalizeScope(scope.Scope)
newScopes = append(newScopes, SourceScope{
Name: source.Path, // backend name is path
Name: source.Path,
Scope: scope.Scope,
})
}
return newScopes, nil
}
// GetFrontendScopes converts the user's scopes from backend-style to frontend-style
// Backend scopes use source paths, frontend scopes use source names
// GetFrontendScopes converts scopes for JSON clients: SourceScope.Name is always the source display name.
// Assumes Bolt stores paths (GetBackendScopes); unknown keys are omitted.
func (u *User) GetFrontendScopes() []SourceScope {
if sourceConfig == nil {
return []SourceScope{}
@@ -78,19 +78,19 @@ func (u *User) GetFrontendScopes() []SourceScope {
newScopes := []SourceScope{}
for _, scope := range u.Scopes {
if source, ok := sourceConfig.GetSourceByPath(scope.Name); ok {
// Replace scope.Name with source.Name while keeping the same Scope value
newScopes = append(newScopes, SourceScope{
Name: source.Name,
Scope: scope.Scope,
})
source, ok := ResolveSourceKey(scope.Name)
if !ok {
continue
}
newScopes = append(newScopes, SourceScope{
Name: source.Name,
Scope: scope.Scope,
})
}
return newScopes
}
// GetBackendSidebarLinks converts the user's sidebar links from frontend-style to backend-style
// Validates that sources exist and converts source names to paths
// GetBackendSidebarLinks normalizes source links for Bolt: SourceName is the filesystem path.
func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
if sourceConfig == nil {
return nil, fmt.Errorf("source config not initialized")
@@ -98,7 +98,6 @@ 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)
@@ -115,7 +114,6 @@ func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
link.SourceName = sourceInfo2.Path
}
}
// Store the link as-is with all fields preserved
newLinks = append(newLinks, link)
}
return newLinks, nil
+59 -20
View File
@@ -7,7 +7,8 @@ import (
jwt "github.com/golang-jwt/jwt/v4"
)
const ()
// CurrentUserMigrationVersion is persisted for newly created accounts and after legacy migrations finish.
const CurrentUserMigrationVersion = 3
type LoginMethod string
@@ -19,6 +20,35 @@ const (
LoginMethodJwt LoginMethod = "jwt"
)
// WebAuthnCredentialFlags mirrors the boolean flags from authenticator data.
type WebAuthnCredentialFlags struct {
UserPresent bool `json:"userPresent"`
UserVerified bool `json:"userVerified"`
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
}
// WebAuthnCredential stores a single passkey credential in BoltDB-friendly format.
type WebAuthnCredential struct {
ID string `json:"id"`
PublicKey string `json:"publicKey"`
AttestationType string `json:"attestationType"`
AttestationFormat string `json:"attestationFormat"`
Transport []string `json:"transport"`
Flags WebAuthnCredentialFlags `json:"flags"`
AAGUID string `json:"aaguid"`
SignCount uint32 `json:"signCount"`
CloneWarning bool `json:"cloneWarning"`
Name string `json:"name"`
CreatedAt int64 `json:"createdAt"`
LastUsedAt int64 `json:"lastUsedAt"`
ClientDataJSON string `json:"clientDataJSON"`
ClientDataHash string `json:"clientDataHash"`
AuthenticatorData string `json:"authenticatorData"`
PublicKeyAlg int64 `json:"publicKeyAlg"`
AttestationObj string `json:"attestationObj"`
}
type AuthToken struct {
MinimalAuthToken
Key string `json:"key,omitempty"` // for backward compatibility
@@ -66,30 +96,31 @@ type Preview struct {
Models bool `json:"models"` // show live thumbnails for 3D models files
}
// User describes a user.
// User describes a persisted account. Scopes and source sidebar links store filesystem paths in Name/SourceName;
// JSON responses use display names only (see http prepForFrontend).
type User struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Scopes []SourceScope `json:"scopes"`
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
Version int `json:"version"`
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Scopes []SourceScope `json:"scopes"`
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
Version int `json:"version"`
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
}
type SourceScope struct {
Name string `json:"name"`
Scope string `json:"scope"`
Name string `json:"name"` // Bolt: filesystem path; JSON API: display name after prepForFrontend
Scope string `json:"scope"` // index path within that source
}
// json tags must match variable name with smaller case first letter
@@ -108,6 +139,7 @@ type NonAdminEditable struct {
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
@@ -121,13 +153,15 @@ type NonAdminEditable struct {
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
ShowFirstLogin bool `json:"showFirstLogin"`
ShowFirstLogin bool `json:"showFirstLogin"`
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
}
type FileLoading struct {
@@ -143,7 +177,7 @@ type SidebarLink struct {
Category string `json:"category"` // Category type: "source", "source-link", "share", "tool", "custom", etc.
Target string `json:"target"` // Target path/URL for the link (relative for source/share)
Icon string `json:"icon"` // Material icon name
SourceName string `json:"sourceName,omitempty"` // Source identifier for source-type links
SourceName string `json:"sourceName,omitempty"` // Bolt: filesystem path. JSON out: display name (after prepForFrontend).
}
func CleanUsername(s string) string {
@@ -190,6 +224,11 @@ func SetSourceConfig(config *SourceConfigProvider) {
sourceConfig = config
}
// SourceConfigLoaded reports whether SetSourceConfig has been called (needed for ResolveSourceKey).
func SourceConfigLoaded() bool {
return sourceConfig != nil
}
// GetScopeForSourcePath returns the scope for a given source path (backend-style)
// This method works with backend-style scopes where Name is the source path
func (u *User) GetScopeForSourcePath(sourcePath string) (string, error) {
+127
View File
@@ -0,0 +1,127 @@
package users
import (
"encoding/base64"
"fmt"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
// WebAuthnID returns the user handle used by WebAuthn.
func (u *User) WebAuthnID() []byte {
return []byte(fmt.Sprintf("%d", u.ID))
}
// WebAuthnName returns the human-readable name for the user.
func (u *User) WebAuthnName() string {
return u.Username
}
// WebAuthnDisplayName returns the display name for the user.
func (u *User) WebAuthnDisplayName() string {
return u.Username
}
// WebAuthnCredentials returns the stored credentials converted to the library's type.
func (u *User) WebAuthnCredentials() []webauthn.Credential {
creds := make([]webauthn.Credential, len(u.PasskeyCredentials))
for i, c := range u.PasskeyCredentials {
creds[i] = *credentialToLibrary(&c)
}
return creds
}
// HasPasskeyMFA returns true if the user has at least one passkey credential configured.
func (u *User) HasPasskeyMFA() bool {
return len(u.PasskeyCredentials) > 0
}
// credentialToLibrary converts our BoltDB-safe WebAuthnCredential to the library's Credential.
func credentialToLibrary(c *WebAuthnCredential) *webauthn.Credential {
id, _ := base64.RawURLEncoding.DecodeString(c.ID)
pubKey, _ := base64.RawURLEncoding.DecodeString(c.PublicKey)
aaguid, _ := base64.RawURLEncoding.DecodeString(c.AAGUID)
cdj, _ := base64.RawURLEncoding.DecodeString(c.ClientDataJSON)
cdh, _ := base64.RawURLEncoding.DecodeString(c.ClientDataHash)
authData, _ := base64.RawURLEncoding.DecodeString(c.AuthenticatorData)
attObj, _ := base64.RawURLEncoding.DecodeString(c.AttestationObj)
transports := make([]protocol.AuthenticatorTransport, len(c.Transport))
for i, t := range c.Transport {
transports[i] = protocol.AuthenticatorTransport(t)
}
return &webauthn.Credential{
ID: id,
PublicKey: pubKey,
AttestationType: c.AttestationType,
AttestationFormat: c.AttestationFormat,
Transport: transports,
Flags: webauthn.CredentialFlags{
UserPresent: c.Flags.UserPresent,
UserVerified: c.Flags.UserVerified,
BackupEligible: c.Flags.BackupEligible,
BackupState: c.Flags.BackupState,
},
Authenticator: webauthn.Authenticator{
AAGUID: aaguid,
SignCount: c.SignCount,
CloneWarning: c.CloneWarning,
},
Attestation: webauthn.CredentialAttestation{
ClientDataJSON: cdj,
ClientDataHash: cdh,
AuthenticatorData: authData,
PublicKeyAlgorithm: c.PublicKeyAlg,
Object: attObj,
},
}
}
// CredentialFromLibrary converts a library Credential to our BoltDB-safe format.
func CredentialFromLibrary(name string, cred *webauthn.Credential) WebAuthnCredential {
transports := make([]string, len(cred.Transport))
for i, t := range cred.Transport {
transports[i] = string(t)
}
now := time.Now().Unix()
return WebAuthnCredential{
ID: base64.RawURLEncoding.EncodeToString(cred.ID),
PublicKey: base64.RawURLEncoding.EncodeToString(cred.PublicKey),
AttestationType: cred.AttestationType,
AttestationFormat: cred.AttestationFormat,
Transport: transports,
Flags: WebAuthnCredentialFlags{
UserPresent: cred.Flags.UserPresent,
UserVerified: cred.Flags.UserVerified,
BackupEligible: cred.Flags.BackupEligible,
BackupState: cred.Flags.BackupState,
},
AAGUID: base64.RawURLEncoding.EncodeToString(cred.Authenticator.AAGUID),
SignCount: cred.Authenticator.SignCount,
CloneWarning: cred.Authenticator.CloneWarning,
Name: name,
CreatedAt: now,
LastUsedAt: now,
ClientDataJSON: base64.RawURLEncoding.EncodeToString(cred.Attestation.ClientDataJSON),
ClientDataHash: base64.RawURLEncoding.EncodeToString(cred.Attestation.ClientDataHash),
AuthenticatorData: base64.RawURLEncoding.EncodeToString(cred.Attestation.AuthenticatorData),
PublicKeyAlg: cred.Attestation.PublicKeyAlgorithm,
AttestationObj: base64.RawURLEncoding.EncodeToString(cred.Attestation.Object),
}
}
// UpdateCredentialFromLibrary updates an existing WebAuthnCredential from the library's Credential
// (used after login to update sign count, flags, and last used time).
func UpdateCredentialFromLibrary(c *WebAuthnCredential, cred *webauthn.Credential) {
c.SignCount = cred.Authenticator.SignCount
c.Flags.UserPresent = cred.Flags.UserPresent
c.Flags.UserVerified = cred.Flags.UserVerified
c.Flags.BackupEligible = cred.Flags.BackupEligible
c.Flags.BackupState = cred.Flags.BackupState
c.LastUsedAt = time.Now().Unix()
}
+56 -43
View File
@@ -11,33 +11,35 @@ require (
github.com/gen2brain/go-fitz v1.24.15
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.17.3
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.21
github.com/mattn/go-sqlite3 v1.14.42
github.com/mattn/go-sqlite3 v1.14.44
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.50.0
golang.org/x/image v0.39.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.53.0
golang.org/x/crypto v0.51.0
golang.org/x/image v0.40.0
golang.org/x/mod v0.36.0
golang.org/x/net v0.54.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.43.0
golang.org/x/sys v0.44.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.44.0
golang.org/x/tools v0.45.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.0
modernc.org/sqlite v1.50.1
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
charm.land/lipgloss/v2 v2.0.3 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
@@ -52,13 +54,14 @@ 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.4.0 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.65.1 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
@@ -66,38 +69,40 @@ require (
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.6.0 // indirect
github.com/bombsimon/wsl/v5 v5.8.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.0 // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -107,6 +112,7 @@ require (
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
@@ -134,24 +140,28 @@ require (
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.5 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20260415063119-550b242b3150 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.11.4 // indirect
github.com/golangci/golangci-lint/v2 v2.12.2 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
@@ -159,17 +169,16 @@ require (
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.8.2 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jgautheron/goconst v1.10.0 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.6.0 // indirect
github.com/jupiterrider/ffi v0.7.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
@@ -186,31 +195,32 @@ require (
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.5.0 // indirect
github.com/manuelarte/funcorder v0.6.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
@@ -228,16 +238,17 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect
github.com/securego/gosec/v2 v2.26.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
@@ -249,16 +260,18 @@ require (
github.com/subosito/gotenv v1.4.1 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tetafro/godot v1.5.4 // indirect
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.4.1 // indirect
github.com/uudashr/iface v1.4.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
@@ -266,7 +279,7 @@ require (
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.11.1 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
@@ -276,12 +289,12 @@ require (
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.72.1 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+122 -120
View File
@@ -2,6 +2,8 @@
4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY=
4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU=
4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -67,14 +69,16 @@ 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.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
@@ -85,8 +89,8 @@ github.com/air-verse/air v1.65.1 h1:G4hy4YemKrIcGAvYMor+86PhxdtBEq0n9ebJ4RoYy8w=
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
@@ -114,12 +118,10 @@ github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtS
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo=
github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c=
github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE=
github.com/ashanbrown/makezero/v2 v2.1.0/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTiLh/8JROI=
github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM=
github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM=
github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -160,8 +162,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs=
github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -169,8 +171,8 @@ github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE=
github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE=
github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg=
github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E=
github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70=
github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I=
github.com/butuzov/ireturn v0.4.1/go.mod h1:q+DXKzTDV5guNuXLnIab9fKXizTn2miZHLhxH7V/GB4=
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
@@ -184,16 +186,18 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk=
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -202,6 +206,10 @@ github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nW
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
@@ -227,8 +235,8 @@ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4m
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
@@ -269,6 +277,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
@@ -367,6 +377,10 @@ github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUN
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@@ -398,11 +412,13 @@ github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTH
github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20260415063119-550b242b3150 h1:F5CQANtNulclG+xJt370ohUujAEHq2XNBBAjkoSukQI=
github.com/golang/geo v0.0.0-20260415063119-550b242b3150/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6 h1:maAhA7cpighmEi96eTgDdmlhwnHYEgwhR6t+T7k3Png=
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -436,14 +452,14 @@ github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw=
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 h1:CbTB8KpqnViI6lIXxp03Oclc4VFHi3K4BWC1TacsZ+A=
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U=
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint/v2 v2.11.4 h1:GK+UlZBN5y7rh2PBnHA93XLSX6RaF7uhzJQ3JwU1wuA=
github.com/golangci/golangci-lint/v2 v2.11.4/go.mod h1:ODQDCASMA3VqfZYIbbQLpTRTzV7O/vjmIRF6u8NyFwI=
github.com/golangci/golangci-lint/v2 v2.12.2 h1:7+d1uY0bq1MU2UV3R5pW5Q7QWdcoq4naMRXM+gsJKrs=
github.com/golangci/golangci-lint/v2 v2.12.2/go.mod h1:opqHHuIcTG2R+4akzWMd4o1BnD9/1LcjICWOujr91U8=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
@@ -452,6 +468,8 @@ github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3H
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k=
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba h1:lqtcnSMDuuJdu/LrKWi5RJzpSNLOJXYe/nzQutTI5kg=
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba/go.mod h1:sCBNcpRmhJCtbFGz49+IM3ETTFf7QdJ30AeYCd43NKk=
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM=
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s=
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM=
@@ -471,6 +489,10 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -515,8 +537,8 @@ github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1T
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -544,10 +566,8 @@ github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4=
github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako=
github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs=
github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c=
github.com/jgautheron/goconst v1.10.0 h1:Ptt+OoE4NaEWKhLrWrrN3IpZdGLiqaf7WLnEX/iv4Jw=
github.com/jgautheron/goconst v1.10.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc=
github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -565,8 +585,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
github.com/jupiterrider/ffi v0.7.0 h1:RKsl6Ascal+3kyAqR5Qcbp83LceQMLc1VZbPfHWoNzs=
github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
@@ -614,8 +634,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY=
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE=
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
@@ -626,8 +646,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww=
github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM=
github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8=
github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA=
github.com/manuelarte/funcorder v0.6.0 h1:0hBngc4fa1IgNiI65A7sFGkMvoMCc878RjqB5V7rWP0=
github.com/manuelarte/funcorder v0.6.0/go.mod h1:id3NDhXdQBmeqXH7eVC6Z89xS6JxvZ8kF9xUxpArU/g=
github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8=
github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ=
github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs=
@@ -640,12 +660,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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
@@ -665,10 +685,10 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc=
github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
@@ -693,8 +713,8 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps=
github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
@@ -708,10 +728,12 @@ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2D
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -756,7 +778,6 @@ github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74
github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -769,6 +790,8 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
github.com/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=
@@ -779,12 +802,10 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
github.com/securego/gosec/v2 v2.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4=
github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
@@ -795,8 +816,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.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
github.com/sourcegraph/go-diff v0.8.0 h1:ipIyu4cTsLbIrln4l0qtHA3r0a7gyK4ntKjtQytHhvY=
github.com/sourcegraph/go-diff v0.8.0/go.mod h1:hWlcO7Al+UZStZAP8rBumHpCK5ZHQ5BXsMls8p4+F5E=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
@@ -842,14 +863,16 @@ github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
github.com/tetafro/godot v1.5.4 h1:u1ww+gqpRLiIA16yF2PV1CV1n/X3zhyezbNXC3E14Sg=
github.com/tetafro/godot v1.5.4/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 h1:SiHe5XLTn9sFWJ5pBwJ5FN/4j34q9ZlOAD//kMoMYp0=
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
@@ -862,12 +885,14 @@ 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.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
github.com/uudashr/iface v1.4.2 h1:06Vq5RKVYThBsj0Bnw4oasMjD1r+7CE/bcKOA8dVSvg=
github.com/uudashr/iface v1.4.2/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -884,7 +909,6 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
@@ -897,8 +921,8 @@ go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
go-simpler.org/sloglint v0.12.0 h1:UzWDlLWNE5FLqsvyq3tWYHuQMbqrervOhT8qPl4Mmw4=
go-simpler.org/sloglint v0.12.0/go.mod h1:jBjjC2bm8rYrs88oTRlFX497kWjJsyZWYoNaXkGRI6I=
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
@@ -913,6 +937,8 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
@@ -926,10 +952,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -948,8 +972,8 @@ golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjO
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -969,14 +993,11 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1009,18 +1030,14 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1042,8 +1059,6 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1081,9 +1096,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1094,17 +1107,11 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1113,10 +1120,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1166,14 +1171,11 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1259,8 +1261,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -1290,10 +1292,10 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
modernc.org/cc/v4 v4.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/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -1302,8 +1304,8 @@ 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.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -1312,8 +1314,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.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+12 -9
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
}
@@ -231,6 +231,9 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
if passwordUser && enforcedOtp && missingOtp {
return http.StatusForbidden, errors.ErrNoTotpConfigured
}
if d.user.HasPasskeyMFA() && d.user.TOTPSecret == "" {
return http.StatusForbidden, errors.ErrPasskeyMFARequired
}
return printToken(w, r, d.user)
}
@@ -337,7 +340,7 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
},
LoginMethod: users.LoginMethodPassword,
}
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions))
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Account.Permissions))
if err != nil {
logger.Debug(err.Error())
// Return the actual error message instead of a generic one
+7
View File
@@ -104,6 +104,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("GET /auth/token", withUser(getApiTokenHandler))
api.HandleFunc("GET /auth/oidc/callback", wrapHandler(oidcCallbackHandler))
api.HandleFunc("GET /auth/oidc/login", wrapHandler(oidcLoginHandler))
api.HandleFunc("POST /auth/webauthn/begin-login", withoutUser(beginPasskeyLoginHandler))
api.HandleFunc("POST /auth/webauthn/finish-login", withoutUser(finishPasskeyLoginHandler))
api.HandleFunc("POST /auth/webauthn/begin-register", withUser(beginPasskeyRegistrationHandler))
api.HandleFunc("POST /auth/webauthn/finish-register", withUser(finishPasskeyRegistrationHandler))
api.HandleFunc("DELETE /auth/webauthn/{id}", withUser(deletePasskeyCredentialHandler))
// ========================================
// Resources Routes - /api/resources/ (with public routes)
@@ -177,7 +182,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
// ========================================
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
api.HandleFunc("GET /media/metadata", withUser(metadataHandler))
api.HandleFunc("GET /media/lyrics", withUser(lyricsHandler))
publicApi.HandleFunc("GET /media/metadata", withHashFile(publicMetadataHandler))
publicApi.HandleFunc("GET /media/lyrics", withHashFile(publicLyricsHandler))
// ========================================
// OnlyOffice Routes - /api/office/ (with public routes)
+1 -1
View File
@@ -229,7 +229,7 @@ func getOrCreateLdapUser(username string, groups []string) (*users.User, error)
}
// Auto-create user on first LDAP authentication
if ldapCfg.AdminGroup == "" {
isAdmin = config.UserDefaults.Permissions.Admin
isAdmin = config.UserDefaults.Account.Permissions.Admin
}
user = &users.User{
Username: username,
+70
View File
@@ -2,6 +2,7 @@ package http
import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
@@ -130,6 +131,7 @@ func metadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
AlbumArt: albumArt,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: false,
ShowSharedAttr: true,
}, store.Access, d.user, store.Share)
@@ -167,6 +169,7 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestCon
AlbumArt: albumArt,
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 {
@@ -175,3 +178,70 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestCon
fileInfo.Path = utils.AddTrailingSlashIfNotExists(path)
return renderJSON(w, r, fileInfo)
}
// lyricsHandler returns synced/unsynced lyrics (with or without timestamps) for an audio file (embedded or from .lrc files).
// @Summary Get lyrics for an audio file
// @Description Returns parsed lyrics with optional timestamps from embedded tags or sidecar .lrc files.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the directory or file"
// @Param source query string true "Source name"
// @Success 200 {object} map[string]interface{} "Lyrics array"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Not found"
// @Router /api/media/lyrics [get]
func lyricsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, err := idx.GetRealPath(userscope, path)
if err != nil {
return http.StatusNotFound, err
}
lyrics, err := files.ExtractLyrics(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract lyrics: %v", err)
}
return renderJSON(w, r, map[string]any{"lyrics": lyrics})
}
// publicLyricsHandler is the share-link variant of lyricsHandler.
// @Summary Get lyrics for an audio file (public share)
// @Tags Shares
// @Produce json
// @Param hash query string true "Share hash"
// @Param path query string false "Path within the share"
// @Success 200 {object} map[string]interface{} "Lyrics array"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Not found"
// @Router /public/api/media/lyrics [get]
func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceCfg, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
idx := indexing.GetIndex(sourceCfg.Name)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found")
}
realPath, _, err := idx.GetRealPath(d.IndexPath)
if err != nil {
return http.StatusNotFound, errors.New("could not find real path for file")
}
lyrics, err := files.ExtractLyrics(realPath)
if err != nil {
return http.StatusInternalServerError, errors.New("failed to extract lyrics")
}
return renderJSON(w, r, map[string]any{"lyrics": lyrics})
}
+3 -2
View File
@@ -134,6 +134,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
AlbumArt: strings.Contains(r.URL.Path, "/preview"),
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ShowHidden: link.ShowHidden,
HideFileExt: link.HideFileExt,
FollowSymlinks: true,
}, store.Access, data.shareUser, store.Share)
if err != nil {
@@ -834,8 +835,8 @@ func LoggingMiddleware(next http.Handler) http.Handler {
}
duration := time.Since(start)
// Use the StatusCode from wrappedWriter, which might have been set to 500 by the recover logic
logger.Api(wrappedWriter.StatusCode,
// ApiPathExclude is applied per logging sink inside go-logger (logger.ApiPath).
logger.ApiPath(wrappedWriter.StatusCode, fullURL,
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode,
-2
View File
@@ -255,8 +255,6 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// --- Attempt to process ID Token ---
if ok && rawIDToken != "" {
logger.Debugf("ID token found in token response, attempting verification. Raw ID Token: %s", rawIDToken)
// Verify the ID token
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
idToken, verify_err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
+3
View File
@@ -101,6 +101,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
Metadata: getMetadata,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: skipExtendedAttrs,
ShowSharedAttr: true,
}, store.Access, d.user, store.Share)
@@ -157,6 +158,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
Source: source,
Expand: false,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
@@ -1049,6 +1051,7 @@ func patchAction(ctx context.Context, params patchActionParams) error {
Source: params.srcIndex,
IsDir: params.isSrcDir,
ShowHidden: params.d.user.ShowHidden,
HideFileExt: params.d.user.HideFileExt,
}, store.Access, params.d.user, store.Share)
if err != nil {
+204 -54
View File
@@ -11,19 +11,29 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
type searchOptions struct {
query string
parsed iteminfo.SearchOptions
sources []string
searchScope string
combinedPath map[string]string // source -> combinedPath
sessionId string
largest bool
useWildcard bool
olderThanUnix int64 // optional; 0 = unset. Modified time must be strictly before this Unix second.
newerThanUnix int64 // optional; 0 = unset. Modified time must be >= this Unix second.
}
// scopedSourcePath is one repeated "scope" query value using "sourceName:relativePath"
// (split on the first ':'). Path is relative within the user's scope for that source; an
// empty path after ':' means "/" . Example: scope=mydisk:/photos&scope=backup:/archive
type scopedSourcePath struct {
source string
relPath string
}
// searchHandler handles search requests for files based on the provided query.
//
// This endpoint processes a search query, retrieves relevant file paths, and
@@ -31,28 +41,43 @@ type searchOptions struct {
// against the file index, which is built from the root directory specified in
// the server's configuration. The results are filtered based on the user's scope.
//
// The handler supports searching a single source (using the 'source' parameter)
// or multiple sources (using the 'sources' parameter). When multiple sources
// are specified, the scope is always set to the user's scope for each source.
// Per-source search scope (preferred for multi-source):
//
// Repeated query parameter "scope" with the value "sourceName:relativePath",
// split on the first colon. The path is user-relative (same as the legacy single-scope
// path). Encode the whole value with the normal query string rules (e.g. %3A for ':' if needed).
// Examples:
// ?scope=mydisk:/&scope=backup:/Photos
// Duplicate source names: the last repeated scope for that source wins.
//
// Legacy (still supported):
//
// - source / sources: which catalogues to search
// - scope: a single path (no 'source:' prefix) applied only when exactly one source is specified.
// When multiple sources are listed and no "source:path" scopes are used, each source is searched
// from the root of the user's scope (same as before).
//
// The handler expects the following headers in the request:
// - SessionId: A unique identifier for the user's session.
//
// The request URL should include query parameters:
// - query: The search terms to use (required)
// - query: Structured filter prefix only, or full legacy search string when "terms" parameters are not used
// - terms: Repeated query parameter; each value is one literal search term. OR-combined by default; use termJoin=and for AND.
// - termJoin: Optional; "and" requires every term to match; any other value keeps OR semantics (default).
// - source: Source name (deprecated, use 'sources' instead)
// - sources: Comma-separated list of source names (e.g., "source1,source2")
// - scope: Optional path within user scope to search
// - sources: Comma-separated list of source names (e.g., "source1,source2") when not using scoped scope=source:path params
// - scope: Either (1) repeated "sourceName:relativePath" as above, or (2) legacy single path within user scope for a single-source search
// - olderThan: Optional Unix time in seconds; only items modified strictly before this instant
// - newerThan: Optional Unix time in seconds; only items modified on or after this instant
// - useWildcard: Optional; when true, file names are matched with SQLite GLOB (wildcards) instead of substring search. Legacy query params glob and useGlob are accepted as aliases.
//
// Example request (single source):
//
// GET api/tools/search?query=myfile&source=mysource
//
// Example request (multiple sources):
// Example request (multiple sources with per-source folders):
//
// GET api/tools/search?query=myfile&sources=source1,source2
// GET api/tools/search?query=myfile&scope=source1:/&scope=source2:/documents
//
// Example response:
// [
@@ -75,13 +100,17 @@ type searchOptions struct {
// @Tags Tools
// @Accept json
// @Produce json
// @Param query query string true "Search query"
// @Param query query string false "Filter prefix or full legacy search text (required when no terms are supplied)"
// @Param terms query []string false "Repeated: one literal search term per parameter; combined with OR unless termJoin=and"
// @Param source query string false "Source name for the desired source (deprecated, use 'sources' instead)"
// @Param sources query string false "Comma-separated list of source names to search across multiple sources. When multiple sources are specified, scope is always the user's scope for each source."
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only. Ignored when multiple sources are specified."
// @Param sources query string false "Comma-separated source names when not using repeated scope=source:path"
// @Param scope query []string false "Repeated: either 'sourceName:relativePath' per source, or legacy single path when one source"
// @Param olderThan query int false "Unix seconds; only results modified strictly before this time"
// @Param newerThan query int false "Unix seconds; only results modified on or after this time"
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
// @Param useWildcard query bool false "When true, match indexed file names with SQLite GLOB (wildcard patterns)"
// @Param glob query bool false "Deprecated: alias for useWildcard"
// @Param useGlob query bool false "Deprecated: alias for useWildcard"
// @Param termJoin query string false "Optional: 'and' to require all repeated 'terms' match; default is OR"
// @Success 200 {array} indexing.SearchResult "List of search results with source field populated"
// @Failure 400 {object} map[string]string "Bad Request"
// @Router /api/tools/search [get]
@@ -104,10 +133,10 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.sources[0])
}
combinedPath := searchOptions.combinedPath[searchOptions.sources[0]]
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
response = index.SearchParsed(searchOptions.parsed, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix, searchOptions.useWildcard)
} else {
// Multiple sources - use the new SearchMultiSources function
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
response = indexing.SearchMultiSourcesParsed(searchOptions.parsed, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix, searchOptions.useWildcard)
}
// Filter out items that are not permitted according to access rules and trim user scope from paths
@@ -125,16 +154,85 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
result.Path = "/"
}
filteredResponse = append(filteredResponse, result)
// This is to filter the ext-hidden files from search results, like the ones with the hidden property
if d.user.HideFileExt != "" {
filtered := filteredResponse[:0]
for _, res := range filteredResponse {
if res.Type == "directory" {
filtered = append(filtered, res)
continue
}
baseName := filepath.Base(res.Path)
if !utils.HideFileByExt(baseName, d.user.HideFileExt) {
filtered = append(filtered, res)
}
}
filteredResponse = filtered
}
}
return renderJSON(w, r, filteredResponse)
}
// parseRepeatedScopeParams interprets repeated "scope" query values.
// If a value contains ':', it is "sourceName:relativePath" (first ':' separates source and path).
// Otherwise it is a legacy single path (at most one such value across all scope params).
func parseRepeatedScopeParams(scopeQueryValues []string) ([]scopedSourcePath, string, error) {
var clauses []scopedSourcePath
legacyPath := ""
for _, raw := range scopeQueryValues {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
idx := strings.IndexByte(raw, ':')
if idx > 0 {
src := strings.TrimSpace(raw[:idx])
if src == "" {
return nil, "", fmt.Errorf("invalid scope parameter %q: empty source name before ':'", raw)
}
pathPart := strings.TrimSpace(raw[idx+1:])
if pathPart == "" {
pathPart = "/"
}
cleanPath, err := utils.SanitizeUserPath(pathPart)
if err != nil {
return nil, "", fmt.Errorf("invalid path in scope parameter %q: %v", raw, err)
}
clauses = append(clauses, scopedSourcePath{source: src, relPath: cleanPath})
continue
}
if idx == 0 {
return nil, "", fmt.Errorf("invalid scope parameter %q: source name must precede ':'", raw)
}
if legacyPath != "" {
return nil, "", fmt.Errorf("multiple legacy scope paths without a source prefix are not allowed; use scope=sourceName:path for each source")
}
clean, err := utils.SanitizeUserPath(raw)
if err != nil {
return nil, "", fmt.Errorf("invalid scope: %v", err)
}
legacyPath = clean
}
return clauses, legacyPath, nil
}
func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, error) {
query := r.URL.Query().Get("query")
rawTerms := r.URL.Query()["terms"]
termJoin := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("termJoin")))
matchAllTerms := termJoin == "and"
sourcesParam := r.URL.Query().Get("sources")
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
scopeValues := r.URL.Query()["scope"] // repeated; use slice, not Get (first only)
largest := r.URL.Query().Get("largest") == "true"
wildRaw := strings.TrimSpace(r.URL.Query().Get("useWildcard"))
if wildRaw == "" {
wildRaw = strings.TrimSpace(r.URL.Query().Get("glob"))
}
if wildRaw == "" {
wildRaw = strings.TrimSpace(r.URL.Query().Get("useGlob"))
}
useWildcard := strings.EqualFold(wildRaw, "true") || wildRaw == "1"
olderThanUnix, err := parseOptionalUnixQueryParam("olderThan", r.URL.Query().Get("olderThan"))
if err != nil {
return nil, err
@@ -143,64 +241,116 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
if err != nil {
return nil, err
}
if scope == "" {
scope = "/"
}
cleanScope, err := utils.SanitizeUserPath(scope)
scopedClauses, legacyScopeOnly, err := parseRepeatedScopeParams(scopeValues)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
}
scope = cleanScope
var sources []string
if sourcesParam != "" {
sources = strings.Split(sourcesParam, ",")
} else if sourceParam != "" {
sources = []string{sourceParam}
} else {
return nil, fmt.Errorf("either 'source' or 'sources' query parameter is required")
return nil, err
}
// Validate all sources exist
for _, source := range sources {
index := indexing.GetIndex(source)
if index == nil {
return nil, fmt.Errorf("index not found for source %s", source)
normalizedTerms := make([]string, 0, len(rawTerms))
for _, t := range rawTerms {
t = strings.TrimSpace(t)
if t != "" {
normalizedTerms = append(normalizedTerms, t)
}
}
if len(query) < settings.Config.Server.MinSearchLength && !largest {
return nil, fmt.Errorf("query is too short, minimum length is %d", settings.Config.Server.MinSearchLength)
}
searchScope := strings.TrimPrefix(scope, ".")
parsed := iteminfo.BuildSearchOptionsFromQuery(query, normalizedTerms, matchAllTerms)
// If multiple sources, always use user scope (ignore searchScope)
if len(sources) > 1 {
searchScope = ""
minLen := settings.Config.Server.MinSearchLength
if !largest {
if len(normalizedTerms) > 0 {
for _, t := range normalizedTerms {
if len(t) < minLen {
return nil, fmt.Errorf("each term is too short, minimum length is %d", minLen)
}
}
} else if len(strings.TrimSpace(query)) < minLen {
return nil, fmt.Errorf("query is too short, minimum length is %d", minLen)
}
}
// Retrieve the User-Agent and X-Auth headers from the request
sessionId := r.Header.Get("SessionId")
// Build combinedPath map for each source
var sources []string
combinedPathMap := make(map[string]string)
for _, source := range sources {
index := indexing.GetIndex(source)
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return nil, err
var searchScopeOut string
if len(scopedClauses) > 0 {
if legacyScopeOnly != "" {
return nil, fmt.Errorf("cannot mix legacy scope path with scope=sourceName:path parameters")
}
pathBySource := make(map[string]string)
for _, c := range scopedClauses {
if _, ok := pathBySource[c.source]; !ok {
sources = append(sources, c.source)
}
pathBySource[c.source] = c.relPath
}
for _, source := range sources {
index := indexing.GetIndex(source)
if index == nil {
return nil, fmt.Errorf("index not found for source %s", source)
}
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return nil, err
}
rel := strings.TrimPrefix(pathBySource[source], ".")
combinedPathMap[source] = index.MakeIndexPath(filepath.Join(userscope, rel), true)
}
} else {
if sourcesParam != "" {
sources = strings.Split(sourcesParam, ",")
} else if sourceParam != "" {
sources = []string{sourceParam}
} else {
return nil, fmt.Errorf("either 'source', 'sources', or repeated scope=sourceName:path query parameters are required")
}
for i := range sources {
sources[i] = strings.TrimSpace(sources[i])
}
for _, source := range sources {
index := indexing.GetIndex(source)
if index == nil {
return nil, fmt.Errorf("index not found for source %s", source)
}
}
scope := legacyScopeOnly
if scope == "" {
scope = "/"
}
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
}
searchScopeOut = strings.TrimPrefix(cleanScope, ".")
if len(sources) > 1 {
searchScopeOut = ""
}
for _, source := range sources {
index := indexing.GetIndex(source)
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return nil, err
}
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScopeOut), true)
combinedPathMap[source] = combinedPath
}
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope), true) // searchScope is a directory
combinedPathMap[source] = combinedPath
}
return &searchOptions{
query: query,
parsed: parsed,
sources: sources,
searchScope: searchScope,
searchScope: searchScopeOut,
combinedPath: combinedPathMap,
sessionId: sessionId,
largest: largest,
useWildcard: useWildcard,
olderThanUnix: olderThanUnix,
newerThanUnix: newerThanUnix,
}, nil
+33 -31
View File
@@ -187,37 +187,39 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
"name": config.Frontend.Name,
"minSearchLength": config.Server.MinSearchLength,
"disableExternal": config.Frontend.DisableDefaultLinks,
"darkMode": settings.Config.UserDefaults.DarkMode,
"baseURL": config.Server.BaseURL,
"version": versionString,
"commitSHA": commitSHAString,
"signup": settings.Config.Auth.Methods.PasswordAuth.Signup,
"noAuth": config.Auth.Methods.NoAuth,
"enableThumbs": !config.Server.DisablePreviews,
"externalLinks": externalLinks,
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"jwtAvailable": config.Auth.Methods.JwtAuth.Enabled,
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"ldapAvailable": config.Auth.Methods.LdapAuth.Enabled,
"mediaAvailable": settings.MediaEnabled(),
"exiftoolAvailable": settings.Config.Integrations.Media.ExiftoolPath != "",
"muPdfAvailable": settings.Env.MuPdfAvailable,
"updateAvailable": utils.GetUpdateAvailableUrl(),
"disableNavButtons": disableNavButtons,
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
"enableHeicConversion": settings.CanConvertImage("heic"),
"eventBasedThemes": !config.Frontend.Styling.DisableEventBasedThemes,
"loginIcon": loginIcon,
"disableSidebar": disableSidebar,
"shareHash": shareHash,
"oidcLoginButtonText": config.Frontend.OIDCLoginButtonText,
"loginButtonText": config.Frontend.LoginButtonText,
"name": config.Frontend.Name,
"minSearchLength": config.Server.MinSearchLength,
"disableExternal": config.Frontend.DisableDefaultLinks,
"darkMode": settings.Config.UserDefaults.UI.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,
"passkeyAvailable": config.Auth.Methods.PasskeyAuth.Enabled,
"passkeyLoginButtonText": config.Auth.Methods.PasskeyAuth.LoginButtonText,
}
// Marshal each variable to JSON strings for direct template usage
+38 -10
View File
@@ -91,6 +91,7 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
return renderJSON(w, r, u)
}
// prepForFrontend strips secrets and maps source identifiers to display names for JSON (never expose paths).
func prepForFrontend(u *users.User) {
u.Password = ""
u.ApiKeys = nil
@@ -99,30 +100,57 @@ func prepForFrontend(u *users.User) {
u.TOTPSecret = ""
u.TOTPNonce = ""
u.Scopes = u.GetFrontendScopes()
u.SidebarLinks = GetFrontendSidebarLinks(u.SidebarLinks)
u.SidebarLinks = GetFrontendSidebarLinks(u.SidebarLinks, u.ShowToolsInSidebar)
u.Locale = normalizeLocale(u.Locale)
for i := range u.PasskeyCredentials {
u.PasskeyCredentials[i].PublicKey = ""
u.PasskeyCredentials[i].AttestationType = ""
u.PasskeyCredentials[i].AttestationFormat = ""
u.PasskeyCredentials[i].Flags = users.WebAuthnCredentialFlags{}
u.PasskeyCredentials[i].SignCount = 0
u.PasskeyCredentials[i].ClientDataJSON = ""
u.PasskeyCredentials[i].ClientDataHash = ""
u.PasskeyCredentials[i].AuthenticatorData = ""
u.PasskeyCredentials[i].PublicKeyAlg = 0
u.PasskeyCredentials[i].AttestationObj = ""
}
}
// GetFrontendSidebarLinks converts the user's sidebar links from backend-style to frontend-style
// Converts source paths to source names for the frontend
func GetFrontendSidebarLinks(links []users.SidebarLink) []users.SidebarLink {
// 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 {
// For source links, validate that the source still exists
if strings.HasPrefix(link.Category, "source") {
source, ok := settings.Config.Server.SourceMap[link.SourceName]
if link.SourceName == "" {
continue
}
source, ok := users.ResolveSourceKey(link.SourceName)
if !ok {
continue
}
if source.Config.ResolvedRules.IndexingDisabled && link.Category != "source-minimal" {
link.Category = "source-alt"
if full, ok := settings.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
}
// For share links, just pass through (shares are validated separately)
// For all other links, pass through as-is
newLinks = append(newLinks, link)
}
if !hasTools && showToolsInSidebar {
newLinks = append(newLinks, users.SidebarLink{
Name: "Tools",
Category: "tool",
Target: "/tools",
Icon: "build",
})
}
return newLinks
}
+232
View File
@@ -0,0 +1,232 @@
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
}
registerRequestOrigin(r)
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
}
registerRequestOrigin(r)
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
}
registerRequestOrigin(r)
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
}
registerRequestOrigin(r)
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
}
// registerRequestOrigin registers the request's origin and RP ID with the WebAuthn service.
func registerRequestOrigin(r *http.Request) {
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)
rpID := host
if i := strings.LastIndex(rpID, ":"); i != -1 {
rpID = rpID[:i]
}
svc := auth.GetWebAuthn()
if svc != nil {
svc.EnsureOrigin(origin)
svc.EnsureRPID(rpID)
}
}
+2
View File
@@ -84,6 +84,7 @@ func (ffs *filteredFileSystem) getFileInfo(requestPath string, expand bool) (*it
Source: ffs.source,
Expand: expand,
ShowHidden: ffs.user.ShowHidden,
HideFileExt: ffs.user.HideFileExt,
SkipExtendedAttrs: true,
FollowSymlinks: true,
}, store.Access, ffs.user, store.Share)
@@ -382,6 +383,7 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
Path: requestPath,
Source: source,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
}, store.Access, d.user)
if err != nil {
return http.StatusForbidden, err
+46 -61
View File
@@ -63,9 +63,10 @@ type Options struct {
IsRoutineScan bool // whether this is a routine/scheduled scan (vs initial indexing)
// Filesystem info retrieval options
SkipExtendedAttrs bool // Skip hasPreview and other extended attributes
FollowSymlinks bool // Whether to follow symlinks or return symlink info
ShowHidden bool // Whether to include hidden files/directories
SkipExtendedAttrs bool // Skip hasPreview and other extended attributes
FollowSymlinks bool // Whether to follow symlinks or return symlink info
ShowHidden bool // Whether to include hidden files/directories
HideFileExt string // Hide files based on extensions
}
// ScannerInfo is the exposed scanner information for the client
@@ -100,14 +101,19 @@ type ReducedIndex struct {
type Index struct {
ReducedIndex
settings.Source `json:"-"`
db *dbsql.IndexDB
previousNumDirs uint64 // Track previous NumDirs to use when scan in progress (computed value is 0)
previousNumFiles uint64 // Track previous NumFiles to use when scan in progress (computed value is 0)
scanners map[string]*Scanner // path -> scanner
mock bool
mu sync.RWMutex
childScanMutex sync.Mutex // Serializes child scanner execution (only one child scanner runs at a time)
settings.Source `json:"-"`
db *dbsql.IndexDB
previousNumDirs uint64 // Track previous NumDirs to use when scan in progress (computed value is 0)
previousNumFiles uint64 // Track previous NumFiles to use when scan in progress (computed value is 0)
scanners map[string]*Scanner // path -> scanner
mock bool
mu sync.RWMutex
activeScannerPath string
schedulerStop chan struct{}
schedulerStopOnce sync.Once
schedulerBatch int // >0: serial pass in progress; suppresses PostScan inside executeScan
// Adaptive scheduler: run one serial scan on process start (then normal slot cadence).
schedulerStartupPassPending bool
// In-memory folder size tracking (not stored in SQLite)
folderSizes map[string]uint64 // path -> size (in-memory only, calculated from children)
folderSizesUnsynced map[string]struct{} // Tracks which folder sizes have changed since last DB sync
@@ -117,6 +123,10 @@ type Index struct {
scanUpdatedPaths map[string]bool // Tracks directories updated by the scan (to distinguish from API updates)
// WebDAV lock system for this source (isolated per source)
WebdavLock webdav.LockSystem
// Adaptive scheduler: shared slot map (UTC unix seconds -> scanners). Only used when IndexingInterval == 0.
scheduleSlotsMu sync.Mutex
scheduleSlots map[int64][]*Scanner
}
var (
@@ -304,18 +314,14 @@ func (idx *Index) getStatusUnlocked() IndexStatus {
}
allScannedAtLeastOnce := true
anyScannerActive := false
for _, scanner := range idx.scanners {
if scanner.lastScanned.IsZero() {
allScannedAtLeastOnce = false
}
if scanner.isScanning {
anyScannerActive = true
}
}
if anyScannerActive || idx.getActiveScannerPathUnlocked() != "" {
if idx.getActiveScannerPathUnlocked() != "" {
return INDEXING
} else if allScannedAtLeastOnce {
return READY
@@ -356,11 +362,7 @@ func StopAllScanners() {
indexesMutex.Lock()
defer indexesMutex.Unlock()
for _, idx := range indexes {
idx.mu.Lock()
for _, scanner := range idx.scanners {
scanner.stop()
}
idx.mu.Unlock()
idx.stopScheduler()
}
}
@@ -429,9 +431,10 @@ type FileInfoRequest struct {
IndexPath string
FollowSymlinks bool
ShowHidden bool
Expand bool // get child items for directories
IsRoutineScan bool // scanner vs API call
SkipExtendedAttrs bool // whether to skip extended attributes
HideFileExt string // Hide files based on their extension
Expand bool // get child items for directories
IsRoutineScan bool // scanner vs API call
SkipExtendedAttrs bool // whether to skip extended attributes
}
// resolvePathContext resolves all path characteristics in a SINGLE stat call
@@ -568,6 +571,7 @@ func (idx *Index) getDirInfoFromContext(ctx *PathContext, isViewable, isIndexabl
SkipExtendedAttrs: req.SkipExtendedAttrs || !isIndexable,
FollowSymlinks: req.FollowSymlinks,
ShowHidden: req.ShowHidden,
HideFileExt: req.HideFileExt,
}
return idx.GetDirInfoCore(dir, ctx.FileInfo, ctx.IndexPath, opts, nil)
@@ -1027,6 +1031,7 @@ func (idx *Index) RefreshDirectory(indexPath string, recursive bool) error {
if idx.Config.ResolvedRules.IndexingDisabled {
return nil
}
if !strings.HasSuffix(indexPath, "/") {
indexPath = indexPath + "/"
}
@@ -1504,32 +1509,7 @@ func (idx *Index) getActiveScannerPath() string {
// getActiveScannerPathUnlocked returns the path of the currently active scanner, or empty string if none
func (idx *Index) getActiveScannerPathUnlocked() string {
for path, scanner := range idx.scanners {
if scanner.isScanning {
return path
}
}
return ""
}
// getRunningScannerCount returns the number of scanners currently running
// Assumes mutex is NOT held - will acquire its own lock
func (idx *Index) getRunningScannerCount() int {
idx.mu.RLock()
defer idx.mu.RUnlock()
return idx.getRunningScannerCountUnlocked()
}
// getRunningScannerCountUnlocked returns the number of scanners currently running
// Assumes mutex IS already held (RLock or Lock)
func (idx *Index) getRunningScannerCountUnlocked() int {
count := 0
for _, scanner := range idx.scanners {
if scanner.isScanning {
count++
}
}
return count
return idx.activeScannerPath
}
// input should be non-index path.
@@ -1569,6 +1549,22 @@ 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.
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 {
@@ -1613,17 +1609,6 @@ func (idx *Index) writePersistedIndexInfo() error {
return indexingStorage.Save(info)
}
// Save persists the index and scanner information to the database and notifies clients via SSE.
func (idx *Index) Save() error {
if err := idx.writePersistedIndexInfo(); err != nil {
return err
}
if err := idx.SendSourceUpdateEvent(); err != nil {
logger.Errorf("[%s] Failed to send source update event: %v", idx.Name, err)
}
return nil
}
// Load restores index and scanner information from the database
func (idx *Index) Load() error {
if indexingStorage == nil {
+82 -150
View File
@@ -15,21 +15,24 @@ import (
type Scanner struct {
scanPath string
// Adaptive scheduling (see Index.useAdaptiveScheduling)
currentSchedule int
smartModifier time.Duration
complexity uint
fullScanCounter int
nextSleepTime time.Duration
calendarSlotSec int64 // UTC unix second in idx.scheduleSlots; 0 if not enrolled
nextRun time.Time // next slot time; zero if not scheduled / due immediately
fullScanCounter int // quick vs full cadence per folder
filesChanged bool
lastScanned time.Time
quickScanTime int
fullScanTime int
statsMu sync.RWMutex
scanStartTime int64
isScanning bool // True when scanner is actively scanning or waiting for mutex
numDirs uint64
numFiles uint64
// Stats (API and persistence)
complexity uint
lastScanned time.Time
quickScanTime int
fullScanTime int
numDirs uint64
numFiles uint64
statsMu sync.RWMutex
// Active scan session
scanStartTime int64
// Per-scanner state (not shared with other scanners)
processedInodes map[uint64]struct{} // Track inodes to detect hardlinks
@@ -37,9 +40,6 @@ type Scanner struct {
batchItems []*iteminfo.FileInfo // Accumulates items for bulk insert
idx *Index
stopChan chan struct{}
stopOnce sync.Once // Ensures stopChan is only closed once
}
func (s *Scanner) withStatsLock(fn func()) {
@@ -54,72 +54,50 @@ func (s *Scanner) withStatsRLock(fn func()) {
fn()
}
// start begins the scanner's main loop
func (s *Scanner) start() {
// Wait a bit to stagger child scanner initial scans (root goes first)
if s.scanPath != "/" {
time.Sleep(500 * time.Millisecond)
}
s.tryAcquireAndScan()
for {
// Check if directory still exists (for non-root scanners)
if s.scanPath != "/" && !s.directoryExists() {
logger.Debugf("Scanner [%s] stopping: directory no longer exists", s.scanPath)
s.removeSelf()
return
}
// Use the sleep time calculated by updateSchedule() (based on OLD schedule before increment)
sleepTime := s.nextSleepTime
if sleepTime == 0 {
// Fallback for first run or if not set
sleepTime = s.calculateSleepTime()
}
select {
case <-s.stopChan:
return
case <-time.After(sleepTime):
s.tryAcquireAndScan()
}
}
}
// tryAcquireAndScan attempts to acquire the global scan mutex and run a scan
func (s *Scanner) tryAcquireAndScan() {
// Ensure isScanning is cleared even if we panic
// executeScan runs one scan for this scanner (invoked only from the per-index scheduler).
func (s *Scanner) executeScan() {
defer func() {
if r := recover(); r != nil {
logger.Errorf("[%s] Scanner panic recovered: %v", s.scanPath, r)
}
s.idx.mu.Lock()
s.isScanning = false
s.idx.activeScannerPath = ""
batch := s.idx.schedulerBatch > 0
s.idx.mu.Unlock()
err := s.idx.PostScan()
if err != nil {
logger.Errorf("Failed to post scan: %v", err)
if !batch {
err := s.idx.PostScan()
if err != nil {
logger.Errorf("Failed to post scan: %v", err)
}
}
}()
// Set isScanning flag for all scanners
if s.scanPath != "/" && !s.directoryExists() {
logger.Debugf("Scanner [%s] skipping: directory no longer exists", s.scanPath)
s.removeSelf()
return
}
s.idx.mu.Lock()
s.isScanning = true
s.idx.activeScannerPath = s.scanPath
s.idx.mu.Unlock()
// Root scanner can run independently since it's non-recursive
// Child scanners must acquire mutex to prevent concurrent child scans
if s.scanPath != "/" {
s.idx.childScanMutex.Lock()
defer s.idx.childScanMutex.Unlock()
}
quick := s.fullScanCounter > 0 && s.fullScanCounter < 5
s.fullScanCounter++
if s.fullScanCounter >= 5 {
s.fullScanCounter = 0
// Cadence per scanner path: full when counter==0, then four quick scans, then wrap (5 runs per cycle).
var pre int
var quick bool
s.withStatsLock(func() {
pre = s.fullScanCounter
quick = pre > 0 && pre < 5
s.fullScanCounter++
if s.fullScanCounter >= 5 {
s.fullScanCounter = 0
}
})
scanMode := "full"
if quick {
scanMode = "quick"
}
logger.Debugf("[%s] scanner [%s] starting %s scan (full/quick cadence step %d of 5)", s.idx.Name, s.scanPath, scanMode, pre+1)
s.runIndexing(quick)
s.updateSchedule()
@@ -339,6 +317,9 @@ func (s *Scanner) runQuickScanChild() {
// Get folders in this scanner's scope from folderSizes map
s.idx.folderSizesMu.RLock()
var foldersToCheck []string
// Scanner root must be modtime-checked: the loop below skips path == scanPath, but only
// checkFolderModtime compares modtime to lastScanned and re-reads the directory for new children.
foldersToCheck = append(foldersToCheck, s.scanPath)
for path := range s.idx.folderSizes {
// Child scanner: check all paths under its scope
if strings.HasPrefix(path, s.scanPath) && path != s.scanPath {
@@ -350,25 +331,6 @@ func (s *Scanner) runQuickScanChild() {
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
// Update the scanner's own root folder to prevent it from being purged
// (child folders are updated via checkFolderModtime, but the scanner's root needs explicit update)
fullScanPath := filepath.Join(s.idx.Path, s.scanPath)
if info, err := os.Stat(fullScanPath); err == nil {
rootFolderInfo := &iteminfo.FileInfo{
Path: s.scanPath,
ItemInfo: iteminfo.ItemInfo{
Name: filepath.Base(strings.TrimSuffix(s.scanPath, "/")),
Size: 0, // Folder size managed in-memory via folderSizes map
ModTime: info.ModTime(),
Type: "directory",
Hidden: false,
HasPreview: false,
},
}
// Add to batch for bulk update
s.batchItems = append(s.batchItems, rootFolderInfo)
}
// Track stats
unchangedCount := 0
changedCount := 0
@@ -416,11 +378,9 @@ func (s *Scanner) checkForNewChildDirectories() {
}
s.idx.mu.RUnlock()
for path, scanner := range existingScanners {
for path := range existingScanners {
if !currentDirsMap[path] {
logger.Debugf("Directory [%s] no longer exists, stopping scanner", path)
scanner.stop()
// Remove from map immediately to prevent stale scanner references
logger.Debugf("Directory [%s] no longer exists, removing scanner", path)
s.idx.mu.Lock()
delete(s.idx.scanners, path)
s.idx.mu.Unlock()
@@ -437,8 +397,6 @@ func (s *Scanner) checkForNewChildDirectories() {
s.idx.mu.Lock()
s.idx.scanners[dirPath] = newScanner
s.idx.mu.Unlock()
go newScanner.start()
}
}
}
@@ -486,65 +444,45 @@ func (s *Scanner) getTopLevelDirs() []string {
return dirs
}
// calculateSleepTime determines how long to wait before the next scan
func (s *Scanner) calculateSleepTime() time.Duration {
var sleepTime time.Duration
s.withStatsRLock(func() {
sleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
})
if s.idx.Config.IndexingInterval > 0 {
sleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
}
return sleepTime
}
// updateSchedule adjusts the scanner's schedule based on whether files changed
// updateSchedule adjusts the scanner's schedule based on whether files changed (adaptive mode only).
func (s *Scanner) updateSchedule() {
s.statsMu.Lock()
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
if s.idx.Config.IndexingInterval > 0 {
s.nextSleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
if !s.idx.useAdaptiveScheduling() {
return
}
s.statsMu.Lock()
// Adjust schedule based on file changes
if s.filesChanged {
logger.Debugf("Scanner [%s] detected changes, adjusting schedule", s.scanPath)
// Move to at least schedule index 3 or reduce interval
// Floor at 40-minute tier (index 3)
if s.currentSchedule > 3 {
s.currentSchedule = 3
} else if s.currentSchedule > 0 {
s.currentSchedule--
}
} else {
// Increment toward the longest interval if no changes
if s.currentSchedule < len(scanSchedule)-1 {
if s.currentSchedule < len(scanScheduleTiers)-1 {
s.currentSchedule++
}
}
// Cap simple complexity (1) at schedule 4
// Don't apply this cap if complexity is still unknown (0)
if s.complexity == 1 && s.currentSchedule > 4 {
s.currentSchedule = 4
}
// Ensure currentSchedule stays within bounds
if s.currentSchedule < 0 {
s.currentSchedule = 0
} else if s.currentSchedule >= len(scanSchedule) {
s.currentSchedule = len(scanSchedule) - 1
}
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()
// Save must NOT run while holding statsMu (see deadlock: GetScannerStatus = idx.mu then
// scanner stats; Save = idx.mu RL while GetIndexInfo may queue idx.mu LW).
if err := s.idx.Save(); err != nil {
logger.Errorf("Failed to save index after schedule update: %v", err)
}
now := time.Now()
nextRun := computeNextSlotTime(lastScanned, now, intervalForNext)
s.statsMu.Lock()
s.nextRun = nextRun
s.statsMu.Unlock()
s.idx.registerScannerNextRun(s, nextRun)
}
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
@@ -552,13 +490,6 @@ func (s *Scanner) updateSchedule() {
func (s *Scanner) updateComplexity() {
s.statsMu.Lock()
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
}
s.statsMu.Unlock()
// Persist after full scans — release statsMu before Save (same ordering as updateSchedule).
@@ -578,6 +509,10 @@ func (s *Scanner) directoryExists() bool {
// removeSelf removes this scanner from the index's scanner map
func (s *Scanner) removeSelf() {
s.idx.scheduleSlotsMu.Lock()
s.idx.removeScannerFromSlotLocked(s)
s.idx.scheduleSlotsMu.Unlock()
s.idx.mu.Lock()
defer s.idx.mu.Unlock()
@@ -585,14 +520,6 @@ func (s *Scanner) removeSelf() {
logger.Infof("Removed scanner [%s] from active scanners", s.scanPath)
}
// stop gracefully stops the scanner
// Uses sync.Once to ensure the channel is only closed once, preventing panic from "close of closed channel"
func (s *Scanner) stop() {
s.stopOnce.Do(func() {
close(s.stopChan)
})
}
func (s *Scanner) syncStatsWithDB() {
var dirs, files uint64
var err error
@@ -674,11 +601,16 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
// Modtime unchanged - update last_updated to prevent folder from being purged
// Note: we DON'T add this folder to scanUpdatedPaths, so Phase 2 won't check its files
// Use cached aggregate size: BulkInsertItems overwrites DB size;0 would corrupt LoadFolderSizes on restart.
var sizeForDB int64
if cached, ok := s.idx.GetFolderSize(folderPath); ok {
sizeForDB = int64(cached)
}
fileInfo := &iteminfo.FileInfo{
Path: folderPath,
ItemInfo: iteminfo.ItemInfo{
Name: filepath.Base(strings.TrimSuffix(folderPath, "/")),
Size: 0, // Folder size is managed in-memory via folderSizes map
Size: sizeForDB,
ModTime: info.ModTime(),
Type: "directory",
Hidden: false,
+71 -38
View File
@@ -5,38 +5,57 @@ import (
"strconv"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
indexingdb "github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/events"
"github.com/gtsteffaniak/go-logger/logger"
)
// schedule in minutes
var scanSchedule = map[int]time.Duration{
0: 5 * time.Minute, // 5 minute scan
1: 10 * time.Minute,
2: 20 * time.Minute,
3: 40 * time.Minute, // schedule index 3 for file changes
4: 1 * time.Hour,
5: 2 * time.Hour,
6: 3 * time.Hour,
7: 4 * time.Hour,
8: 8 * time.Hour,
9: 12 * time.Hour,
// scanScheduleTiers is the only set of adaptive scan intervals (index = currentSchedule tier).
// Fastest tier is 5 minutes so consecutive runs for a path are at least ~5 minutes apart on the grid.
//
// On filesChanged: if current tier is slower than 40 minutes (index > 3), jump to tier 3; otherwise
// step one tier faster (index--). High-complexity scanners are clamped to scheduleTierBoundsForComplexity.
var scanScheduleTiers = []time.Duration{
5 * time.Minute,
10 * time.Minute,
20 * time.Minute,
40 * time.Minute,
1 * time.Hour,
2 * time.Hour,
3 * time.Hour,
4 * time.Hour,
8 * time.Hour,
12 * time.Hour,
}
// complexityModifier defines time adjustments based on complexity level (0-10)
var complexityModifier = map[uint]time.Duration{
0: 0 * time.Minute, // unknown: no modifier
1: -4 * time.Minute, // simple: scan more frequently
2: -2 * time.Minute, // normal (lightest)
3: -1 * time.Minute,
4: 0 * time.Minute, // baseline normal
5: 1 * time.Minute,
6: 2 * time.Minute, // normal (heaviest)
7: 4 * time.Minute, // complex (lightest)
8: 8 * time.Minute,
9: 12 * time.Minute,
10: 16 * time.Minute, // highlyComplex: scan less frequently
func scanScheduleDuration(tier int) time.Duration {
return scanScheduleTiers[utils.Clamp(tier, 0, len(scanScheduleTiers)-1)]
}
// scheduleTierBoundsForComplexity limits how aggressive or idle a scanner may be scheduled.
// minTier: fastest allowed interval (low index); maxTier: slowest allowed (high index).
// Complexity 0 = unknown (e.g. before first full scan): no bounds. Complexity 1: never slower than 1h.
func scheduleTierBoundsForComplexity(c uint) (minTier, maxTier int) {
maxTier = len(scanScheduleTiers) - 1
minTier = 0
if c == 1 {
maxTier = 4 // index 4 = 1 hour; trivial trees should not stretch to multi-hour tiers
}
switch {
case c >= 10:
minTier = 4 // at least 1h between runs
case c >= 8:
minTier = 3 // at least 40m
case c >= 6:
minTier = 2 // at least 20m
case c >= 4:
minTier = 1 // at least 10m
}
if minTier > maxTier {
minTier = maxTier
}
return minTier, maxTier
}
// calculateTimeScore returns a 1-10 score based on scan time
@@ -172,13 +191,11 @@ func (idx *Index) incrementScannerFiles() {
}
}
func (idx *Index) PreScan() error {
return idx.SetStatus(INDEXING)
}
func (idx *Index) PostScan() error {
// Only do expensive operations when ALL scanners are done
if idx.getRunningScannerCount() == 0 {
idx.mu.RLock()
idle := idx.activeScannerPath == ""
idx.mu.RUnlock()
if idle {
// All scanners completed
err := idx.db.ShrinkMemory()
if err != nil {
@@ -249,23 +266,27 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
// Create and start root scanner
rootScanner := idx.createScanner("/")
adaptive := idx.useAdaptiveScheduling()
if persistedScanners != nil {
if rootInfo, ok := persistedScanners["/"]; ok {
rootScanner.withStatsLock(func() {
rootScanner.complexity = rootInfo.Complexity
rootScanner.currentSchedule = rootInfo.CurrentSchedule
if adaptive {
rootScanner.currentSchedule = utils.Clamp(rootInfo.CurrentSchedule, 0, len(scanScheduleTiers)-1)
}
rootScanner.quickScanTime = rootInfo.QuickScanTime
rootScanner.fullScanTime = rootInfo.FullScanTime
rootScanner.numDirs = rootInfo.NumDirs
rootScanner.numFiles = rootInfo.NumFiles
rootScanner.lastScanned = rootInfo.LastScanned
rootScanner.fullScanCounter = utils.Clamp(rootInfo.FullScanCounter, 0, 4)
})
}
}
idx.mu.Lock()
idx.scanners["/"] = rootScanner
idx.schedulerStop = make(chan struct{})
idx.mu.Unlock()
go rootScanner.start()
// Discover existing top-level directories (root scanner will create scanners for new ones dynamically)
topLevelDirs := rootScanner.getTopLevelDirs()
@@ -279,12 +300,15 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
if childInfo, ok := persistedScanners[dirPath]; ok {
childScanner.withStatsLock(func() {
childScanner.complexity = childInfo.Complexity
childScanner.currentSchedule = childInfo.CurrentSchedule
if adaptive {
childScanner.currentSchedule = utils.Clamp(childInfo.CurrentSchedule, 0, len(scanScheduleTiers)-1)
}
childScanner.quickScanTime = childInfo.QuickScanTime
childScanner.fullScanTime = childInfo.FullScanTime
childScanner.numDirs = childInfo.NumDirs
childScanner.numFiles = childInfo.NumFiles
childScanner.lastScanned = childInfo.LastScanned
childScanner.fullScanCounter = utils.Clamp(childInfo.FullScanCounter, 0, 4)
})
}
}
@@ -292,10 +316,17 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
idx.mu.Lock()
idx.scanners[dirPath] = childScanner
idx.mu.Unlock()
go childScanner.start()
}
if idx.useAdaptiveScheduling() {
idx.mu.Lock()
idx.schedulerStartupPassPending = true
idx.mu.Unlock()
}
idx.restoreScannerNextRuns()
go idx.runIndexScheduler()
logger.Debugf("Created %d scanners for [%v] (1 root + %d children)", len(topLevelDirs)+1, idx.Name, len(topLevelDirs))
}
@@ -304,7 +335,6 @@ func (idx *Index) createScanner(dirPath string) *Scanner {
return &Scanner{
scanPath: dirPath,
idx: idx,
stopChan: make(chan struct{}),
currentSchedule: 0,
fullScanCounter: 0,
complexity: 0, // 0 = unknown until first full scan completes
@@ -338,7 +368,10 @@ func (idx *Index) GetScannerStatus() map[string]interface{} {
scannerInfo["numDirs"] = scanner.numDirs
scannerInfo["numFiles"] = scanner.numFiles
scannerInfo["filesChanged"] = scanner.filesChanged
scannerInfo["smartModifier"] = scanner.smartModifier.String()
scannerInfo["fullScanCounter"] = scanner.fullScanCounter
if !scanner.nextRun.IsZero() {
scannerInfo["nextRun"] = scanner.nextRun.Format(time.RFC3339)
}
})
scannerStats = append(scannerStats, scannerInfo)
}
+354
View File
@@ -0,0 +1,354 @@
package indexing
import (
"sort"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// alignCeilToInterval rounds t up (UTC) to the next boundary where Unix time is a multiple of interval.
func alignCeilToInterval(t time.Time, interval time.Duration) time.Time {
if interval < time.Minute {
interval = time.Minute
}
stepNs := interval.Nanoseconds()
total := t.UTC().UnixNano()
aligned := (total + stepNs - 1) / stepNs * stepNs
return time.Unix(0, aligned).UTC()
}
// computeNextSlotTime returns the next scheduled slot after a scan completes.
// interval must be one of scanScheduleTiers; alignment uses that interval as the grid step.
func computeNextSlotTime(lastScanned, ref time.Time, interval time.Duration) time.Time {
minStep := scanScheduleTiers[0]
if interval < minStep {
interval = minStep
}
minNext := lastScanned.Add(interval)
if minNext.Before(ref) {
minNext = ref
}
return alignCeilToInterval(minNext, interval)
}
func (idx *Index) removeScannerFromSlotLocked(s *Scanner) {
sec := s.calendarSlotSec
if sec == 0 {
return
}
if idx.scheduleSlots == nil {
s.calendarSlotSec = 0
return
}
lst := idx.scheduleSlots[sec]
out := lst[:0]
for _, x := range lst {
if x != s {
out = append(out, x)
}
}
if len(out) == 0 {
delete(idx.scheduleSlots, sec)
} else {
idx.scheduleSlots[sec] = out
}
s.calendarSlotSec = 0
}
// registerScannerNextRun removes the scanner from any previous slot and adds it at the given UTC time.
func (idx *Index) registerScannerNextRun(s *Scanner, when time.Time) {
if !idx.useAdaptiveScheduling() {
return
}
when = when.UTC().Truncate(time.Second)
sec := when.Unix()
idx.scheduleSlotsMu.Lock()
defer idx.scheduleSlotsMu.Unlock()
if idx.scheduleSlots == nil {
idx.scheduleSlots = make(map[int64][]*Scanner)
}
idx.removeScannerFromSlotLocked(s)
idx.scheduleSlots[sec] = append(idx.scheduleSlots[sec], s)
s.calendarSlotSec = sec
}
func (idx *Index) takeDueScannerBatch(now time.Time) []*Scanner {
idx.scheduleSlotsMu.Lock()
defer idx.scheduleSlotsMu.Unlock()
if idx.scheduleSlots == nil {
return nil
}
var dueSec int64 = -1
nowSec := now.Unix()
for sec := range idx.scheduleSlots {
if sec <= nowSec {
if dueSec < 0 || sec < dueSec {
dueSec = sec
}
}
}
if dueSec < 0 {
return nil
}
lst := idx.scheduleSlots[dueSec]
delete(idx.scheduleSlots, dueSec)
for _, s := range lst {
s.calendarSlotSec = 0
}
return lst
}
func (idx *Index) earliestSlotWake(now time.Time) time.Time {
idx.scheduleSlotsMu.Lock()
defer idx.scheduleSlotsMu.Unlock()
if len(idx.scheduleSlots) == 0 {
return now.Add(time.Minute)
}
nowSec := now.Unix()
var earliest int64 = -1
for sec := range idx.scheduleSlots {
if sec <= nowSec {
return now
}
if earliest < 0 || sec < earliest {
earliest = sec
}
}
if earliest < 0 {
return now.Add(time.Minute)
}
return time.Unix(earliest, 0).UTC()
}
func (idx *Index) restoreScannerNextRuns() {
if !idx.useAdaptiveScheduling() {
return
}
idx.scheduleSlotsMu.Lock()
idx.scheduleSlots = make(map[int64][]*Scanner)
idx.scheduleSlotsMu.Unlock()
idx.mu.RLock()
n := len(idx.scanners)
idx.mu.RUnlock()
now := time.Now()
for _, s := range idx.scanners {
var next time.Time
s.withStatsLock(func() {
if s.lastScanned.IsZero() {
s.nextRun = time.Time{}
return
}
tier := utils.Clamp(s.currentSchedule, 0, len(scanScheduleTiers)-1)
d := scanScheduleDuration(tier)
next = computeNextSlotTime(s.lastScanned, now, d)
s.nextRun = next
})
if !next.IsZero() {
idx.registerScannerNextRun(s, next)
}
}
logger.Debugf("[%s] scheduler: restored nextRun for %d scanners (adaptive)", idx.Name, n)
}
func sortedScannerPaths(idx *Index) []string {
idx.mu.RLock()
defer idx.mu.RUnlock()
paths := make([]string, 0, len(idx.scanners))
for p := range idx.scanners {
paths = append(paths, p)
}
sort.Strings(paths)
return paths
}
func (idx *Index) pruneGoneChildScanners() {
idx.mu.RLock()
var gone []string
for path, s := range idx.scanners {
if path == "/" {
continue
}
if !s.directoryExists() {
gone = append(gone, path)
}
}
idx.mu.RUnlock()
for _, path := range gone {
idx.mu.RLock()
s, ok := idx.scanners[path]
idx.mu.RUnlock()
if !ok {
continue
}
idx.scheduleSlotsMu.Lock()
idx.removeScannerFromSlotLocked(s)
idx.scheduleSlotsMu.Unlock()
idx.mu.Lock()
delete(idx.scanners, path)
idx.mu.Unlock()
logger.Debugf("Scheduler removed scanner [%s]: path gone", path)
}
}
func (idx *Index) needsAdaptiveBootstrap() bool {
idx.mu.RLock()
defer idx.mu.RUnlock()
if idx.schedulerStartupPassPending {
return true
}
for _, s := range idx.scanners {
if s.lastScanned.IsZero() {
return true
}
}
return false
}
func (idx *Index) runSerialScanPass() {
idx.pruneGoneChildScanners()
paths := sortedScannerPaths(idx)
idx.mu.Lock()
idx.schedulerBatch++
batchDepth := idx.schedulerBatch
idx.mu.Unlock()
logger.Debugf("[%s] scheduler: serialPass start paths=%d batchDepth=%d adaptive=%v",
idx.Name, len(paths), batchDepth, idx.useAdaptiveScheduling())
defer func() {
idx.mu.Lock()
idx.schedulerBatch--
idx.mu.Unlock()
if err := idx.PostScan(); err != nil {
logger.Errorf("PostScan after serial pass: %v", err)
}
logger.Debugf("[%s] scheduler: serialPass done", idx.Name)
}()
for _, p := range paths {
idx.mu.RLock()
s, ok := idx.scanners[p]
idx.mu.RUnlock()
if !ok {
continue
}
if s.scanPath != "/" && !s.directoryExists() {
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)
s.executeScan()
}
}
if !drainedAny {
idx.mu.RLock()
n := len(idx.scanners)
idx.mu.RUnlock()
now := time.Now()
logger.Debugf("[%s] scheduler: adaptive tick no due slot (now=%v scanners=%d)", idx.Name, now.UTC().Format(time.RFC3339), n)
}
}
func (idx *Index) runIndexScheduler() {
defer func() {
if r := recover(); r != nil {
logger.Errorf("[%s] index scheduler panic recovered: %v", idx.Name, r)
}
}()
if !idx.useAdaptiveScheduling() {
idx.mu.RLock()
n := len(idx.scanners)
idx.mu.RUnlock()
interval := time.Duration(idx.Config.IndexingInterval) * time.Minute
logger.Debugf("[%s] scheduler: started fixed interval=%v scanners=%d", idx.Name, interval, n)
idx.runSerialScanPass()
for {
select {
case <-idx.schedulerStop:
logger.Debugf("[%s] scheduler: fixed loop stopped", idx.Name)
return
case <-time.After(interval):
logger.Debugf("[%s] scheduler: fixed interval wake", idx.Name)
idx.runSerialScanPass()
}
}
}
idx.mu.RLock()
n := len(idx.scanners)
idx.mu.RUnlock()
logger.Debugf("[%s] scheduler: started adaptive scanners=%d", idx.Name, n)
idx.schedulerAdaptiveTick()
for {
now := time.Now()
next := idx.earliestSlotWake(now)
d := time.Until(next)
if d < time.Second {
d = time.Second
}
logger.Debugf("[%s] scheduler: adaptive sleep until=%v (in %v)", idx.Name, next.UTC().Format(time.RFC3339), d)
select {
case <-idx.schedulerStop:
logger.Debugf("[%s] scheduler: adaptive loop stopped", idx.Name)
return
case <-time.After(d):
idx.schedulerAdaptiveTick()
}
}
}
func (idx *Index) stopScheduler() {
idx.schedulerStopOnce.Do(func() {
if idx.schedulerStop != nil {
logger.Debugf("[%s] scheduler: stop channel closed", idx.Name)
close(idx.schedulerStop)
}
})
}
@@ -0,0 +1,69 @@
package indexing
import (
"testing"
"time"
)
func TestAlignCeilToFiveMinutes(t *testing.T) {
loc := time.UTC
base := time.Date(2026, 4, 14, 12, 35, 0, 0, loc)
step := 5 * time.Minute
if got := alignCeilToInterval(base, step); !got.Equal(base) {
t.Fatalf("on boundary: got %v want %v", got, base)
}
withSec := time.Date(2026, 4, 14, 12, 35, 1, 0, loc)
want := time.Date(2026, 4, 14, 12, 40, 0, 0, loc)
if got := alignCeilToInterval(withSec, step); !got.Equal(want) {
t.Fatalf("ceil: got %v want %v", got, want)
}
}
func TestComputeNextSlotTimeTenMinuteGrid(t *testing.T) {
loc := time.UTC
last := time.Date(2026, 4, 14, 12, 0, 0, 0, loc)
ref := time.Date(2026, 4, 14, 12, 0, 30, 0, loc)
interval := 10 * time.Minute
got := computeNextSlotTime(last, ref, interval)
want := time.Date(2026, 4, 14, 12, 10, 0, 0, loc)
if !got.Equal(want) {
t.Fatalf("got %v want %v", got, want)
}
}
func TestScheduleTierBoundsForComplexity(t *testing.T) {
n := len(scanScheduleTiers) - 1
tests := []struct {
c uint
min, max int
}{
{0, 0, n},
{1, 0, 4},
{3, 0, n},
{4, 1, n},
{6, 2, n},
{8, 3, n},
{10, 4, n},
}
for _, tt := range tests {
min, max := scheduleTierBoundsForComplexity(tt.c)
if min != tt.min || max != tt.max {
t.Fatalf("complexity %d: got min=%d max=%d want min=%d max=%d", tt.c, min, max, tt.min, tt.max)
}
}
}
func TestComputeNextSlotTimeHourUsesHourGrid(t *testing.T) {
loc := time.UTC
last := time.Date(2026, 4, 14, 12, 0, 0, 0, loc)
ref := time.Date(2026, 4, 14, 12, 3, 0, 0, loc)
interval := 1 * time.Hour
got := computeNextSlotTime(last, ref, interval)
step := int64(interval / time.Second)
if got.Unix()%step != 0 {
t.Fatalf("expected multiple of %v, got %v", interval, got)
}
if got.Before(ref) {
t.Fatalf("got %v before ref %v", got, ref)
}
}
+2 -2
View File
@@ -111,8 +111,8 @@ func TestMultiScannerMutex(t *testing.T) {
}
// Verify scanner counters can be updated
// Test with active scanner - mark scanner2 as scanning
scanner2.isScanning = true
// Test with active scanner
idx.activeScannerPath = "/test1/"
idx.scanners = map[string]*Scanner{"/test1/": scanner2}
idx.incrementScannerDirs()
idx.incrementScannerFiles()
+16 -8
View File
@@ -34,16 +34,24 @@ type FileInfo struct {
IsDir bool `json:"-"`
}
// Lyric is a single line of lyrics with timestamps (if available)
type Lyric struct {
Text string `json:"text"`
Timestamp int64 `json:"timestamp"` // milliseconds
}
// MediaMetadata contains metadata extracted from audio and video files
type MediaMetadata struct {
Title string `json:"title,omitempty"` // track/video title
Artist string `json:"artist,omitempty"` // track artist
Album string `json:"album,omitempty"` // album name
Year int `json:"year,omitempty"` // release year
Genre string `json:"genre,omitempty"` // music/video genre
Track int `json:"track,omitempty"` // track number
Duration int `json:"duration,omitempty"` // duration in seconds
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
Title string `json:"title,omitempty"` // track/video title
Artist string `json:"artist,omitempty"` // track artist
Album string `json:"album,omitempty"` // album name
Year int `json:"year,omitempty"` // release year
Genre string `json:"genre,omitempty"` // music/video genre
Track int `json:"track,omitempty"` // track number
Duration int `json:"duration,omitempty"` // duration in seconds
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
Lyrics []Lyric `json:"lyrics,omitempty"` // lyrics (from embedded tags or .lrc files)
HasLyrics bool `json:"hasLyrics,omitempty"` // checks if lyrics are available without parse them
}
// for efficiency, a response will be a pointer to the data
+17 -14
View File
@@ -317,28 +317,33 @@ func DetectTypeByHeader(realPath string) string {
// returns file type if the file name contains the search term
// returns size of file/dir if the file name contains the search term
func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions) bool {
conditions := options.Conditions
lowerFileName := strings.ToLower(fi.Name)
st := searchTerm
if !conditions["exact"] {
st = strings.ToLower(st)
}
if !strings.Contains(lowerFileName, st) {
return false
}
return fi.MatchesSearchAuxiliaryFilters(options)
}
// MatchesSearchAuxiliaryFilters applies type/size/date constraints from SearchOptions without checking the file name.
// Used when SQLite has already filtered names (e.g. name GLOB patterns).
func (fi ItemInfo) MatchesSearchAuxiliaryFilters(options SearchOptions) bool {
fileTypes := map[string]bool{}
largerThan := int64(options.LargerThan) * 1024 * 1024
smallerThan := int64(options.SmallerThan) * 1024 * 1024
conditions := options.Conditions
lowerFileName := strings.ToLower(fi.Name)
// Convert to lowercase if not exact match
if !conditions["exact"] {
searchTerm = strings.ToLower(searchTerm)
}
// Check if the file name contains the search term
if !strings.Contains(lowerFileName, searchTerm) {
return false
}
// Initialize file size and fileTypes map
var fileSize int64
extension := filepath.Ext(lowerFileName)
// Collect file types using the actual detected MIME type, not just extension
for _, k := range AllFiletypeOptions {
if IsMatchingDetectedType(fi.Type, extension, k) {
fileTypes[k] = true
@@ -348,7 +353,6 @@ func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions)
fileTypes["dir"] = isDir
fileSize = fi.Size
// Evaluate all conditions
for t, v := range conditions {
if t == "exact" {
continue
@@ -367,7 +371,6 @@ func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions)
}
}
default:
// Handle other file type conditions
notMatchType := v != fileTypes[t]
if notMatchType {
return false
+33 -2
View File
@@ -16,7 +16,30 @@ type SearchOptions struct {
ModifiedOlderThan int64
// ModifiedNewerThan is Unix seconds (UTC start of day). If > 0, item must have ModTime >= this instant.
ModifiedNewerThan int64
Terms []string
Terms []string
// MatchAllTerms, when true and non-wildcard search, an item must contain every term (AND). When false (default), any term matches (OR).
// For name GLOB wildcard search, name must match every pattern when true; when false, patterns are OR-combined in SQL.
MatchAllTerms bool
}
// BuildSearchOptionsFromQuery merges optional repeated literal terms (HTTP "terms" parameters) with structured filter text ("query" prefix).
// When termValues has no non-empty entries, parses prefixQuery only (legacy behavior, including | for OR within the string).
func BuildSearchOptionsFromQuery(prefixQuery string, termValues []string, termJoinAnd bool) SearchOptions {
prefixQuery = strings.TrimSpace(prefixQuery)
normalized := make([]string, 0, len(termValues))
for _, t := range termValues {
t = strings.TrimSpace(t)
if t != "" {
normalized = append(normalized, t)
}
}
if len(normalized) == 0 {
return ParseSearch(prefixQuery)
}
opts := ParseSearch(prefixQuery)
opts.Terms = normalized
opts.MatchAllTerms = termJoinAnd
return opts
}
func ParseSearch(value string) SearchOptions {
@@ -99,7 +122,15 @@ func ParseSearch(value string) SearchOptions {
return opts
}
value = strings.TrimSpace(value)
opts.Terms = strings.Split(value, "|")
parts := strings.Split(value, "|")
terms := make([]string, 0, len(parts))
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
terms = append(terms, t)
}
}
opts.Terms = terms
return opts
}
+6 -1
View File
@@ -346,11 +346,16 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
// Build scanner info for client
idx.mu.RLock()
adaptiveSched := idx.useAdaptiveScheduling()
scannerInfos := make([]*ScannerInfo, 0, len(idx.scanners))
for _, scanner := range idx.scanners {
schedule := 0
if adaptiveSched {
schedule = scanner.currentSchedule
}
scannerInfos = append(scannerInfos, &ScannerInfo{
Path: scanner.scanPath,
CurrentSchedule: scanner.currentSchedule,
CurrentSchedule: schedule,
Stats: Stats{
LastScanned: scanner.lastScanned,
Complexity: scanner.complexity,
+119 -115
View File
@@ -28,34 +28,43 @@ type SearchResult struct {
Source string `json:"source"`
}
func (idx *Index) Search(search string, scope string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64) []*SearchResult {
// Ensure scope has consistent trailing slash for directory matching
// Search parses query and delegates to SearchParsed (legacy / tests).
func (idx *Index) Search(search string, scope string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64, useWildcard bool) []*SearchResult {
return idx.SearchParsed(iteminfo.ParseSearch(search), scope, sourceSession, largest, limit, olderThanUnix, newerThanUnix, useWildcard)
}
// SearchParsed runs indexing search using pre-built options (e.g. repeated HTTP term parameters plus filter prefix).
func (idx *Index) SearchParsed(baseOpts iteminfo.SearchOptions, scope string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64, useWildcard bool) []*SearchResult {
scope = utils.AddTrailingSlashIfNotExists(scope)
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := iteminfo.ParseSearch(search)
sessionInProgress.Store(sourceSession, runningHash)
searchOptions := baseOpts
if olderThanUnix > 0 {
searchOptions.ModifiedOlderThan = olderThanUnix
}
if newerThanUnix > 0 {
searchOptions.ModifiedNewerThan = newerThanUnix
}
results := make(map[string]*SearchResult, 0)
results := make(map[string]*SearchResult)
count := 0
// When largest=true and no search terms, ensure we run at least once
if largest && len(searchOptions.Terms) == 0 {
searchOptions.Terms = []string{""}
}
rows, err := idx.db.SearchItems(idx.Name, scope, largest)
nameGlobPatterns := nameGlobPatternsForSearch(searchOptions, useWildcard, largest)
globAnd := useWildcard && searchOptions.MatchAllTerms
rows, err := idx.db.SearchItems(idx.Name, scope, largest, nameGlobPatterns, globAnd)
if err != nil {
return []*SearchResult{}
}
defer rows.Close()
for rows.Next() {
// Check for cancellation
value, found := sessionInProgress.Load(sourceSession)
if !found || value != runningHash {
return []*SearchResult{}
@@ -78,7 +87,6 @@ func (idx *Index) Search(search string, scope string, sourceSession string, larg
continue
}
// Create ItemInfo for matching
item := iteminfo.ItemInfo{
Name: name,
Size: size,
@@ -87,61 +95,30 @@ func (idx *Index) Search(search string, scope string, sourceSession string, larg
HasPreview: hasPreview,
}
// Check against all search terms
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" && !largest {
continue
matches := itemMatchesSearchFilters(item, isDir, searchOptions, largest, useWildcard, nameGlobPatterns)
if matches {
resType := mimeType
if isDir {
resType = "directory"
}
var matches bool
if largest {
// When largest=true, check size and type conditions, skip name matching
largerThan := int64(searchOptions.LargerThan) * 1024 * 1024
sizeMatches := largerThan == 0 || item.Size > largerThan
// Check if directories should be excluded (when type:file is specified)
dirCondition, hasDirCondition := searchOptions.Conditions["dir"]
// For directories: match if dir condition is explicitly true
// For files: match if no dir condition, or if dir condition is false
var typeMatches bool
if isDir {
typeMatches = hasDirCondition && dirCondition
} else {
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
}
dateMatches := searchDateMatches(item.ModTime.Unix(), searchOptions)
matches = sizeMatches && typeMatches && dateMatches
} else {
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
}
if matches {
// Determine type string for response
resType := mimeType
if isDir {
resType = "directory"
}
results[path] = &SearchResult{
Path: path,
Type: resType,
Size: size,
Modified: item.ModTime.Format(time.RFC3339),
HasPreview: hasPreview,
Source: idx.Name,
}
count++
// Break inner loop (terms) if matched, move to next row
break
results[path] = &SearchResult{
Path: path,
Type: resType,
Size: size,
Modified: item.ModTime.Format(time.RFC3339),
HasPreview: hasPreview,
Source: idx.Name,
}
count++
}
}
// Sort keys based on the number of elements in the path after splitting by "/"
sortedKeys := make([]*SearchResult, 0, len(results))
for _, v := range results {
sortedKeys = append(sortedKeys, v)
}
// Sort the strings based on the number of elements after splitting by "/"
sort.Slice(sortedKeys, func(i, j int) bool {
parts1 := strings.Split(sortedKeys[i].Path, "/")
parts2 := strings.Split(sortedKeys[j].Path, "/")
@@ -150,6 +127,64 @@ func (idx *Index) Search(search string, scope string, sourceSession string, larg
return sortedKeys
}
func itemMatchesSearchFilters(item iteminfo.ItemInfo, isDir bool, searchOptions iteminfo.SearchOptions, largest, useWildcard bool, nameGlobPatterns []string) bool {
if largest {
largerThan := int64(searchOptions.LargerThan) * 1024 * 1024
sizeMatches := largerThan == 0 || item.Size > largerThan
dirCondition, hasDirCondition := searchOptions.Conditions["dir"]
var typeMatches bool
if isDir {
typeMatches = hasDirCondition && dirCondition
} else {
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
}
dateMatches := searchDateMatches(item.ModTime.Unix(), searchOptions)
return sizeMatches && typeMatches && dateMatches
}
if useWildcard && len(nameGlobPatterns) > 0 {
return item.MatchesSearchAuxiliaryFilters(searchOptions)
}
if searchOptions.MatchAllTerms {
sawNonempty := false
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" {
continue
}
sawNonempty = true
if !item.ContainsSearchTerm(searchTerm, searchOptions) {
return false
}
}
return sawNonempty
}
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" {
continue
}
if item.ContainsSearchTerm(searchTerm, searchOptions) {
return true
}
}
return false
}
// nameGlobPatternsForSearch builds non-empty parsed terms for SQLite name GLOB OR clauses.
func nameGlobPatternsForSearch(opts iteminfo.SearchOptions, useWildcard, largest bool) []string {
if largest || !useWildcard || len(opts.Terms) == 0 {
return nil
}
var patterns []string
for _, t := range opts.Terms {
if t != "" {
patterns = append(patterns, t)
}
}
if len(patterns) == 0 {
return nil
}
return patterns
}
func searchDateMatches(modUnix int64, opts iteminfo.SearchOptions) bool {
if opts.ModifiedNewerThan > 0 && modUnix < opts.ModifiedNewerThan {
return false
@@ -160,23 +195,22 @@ func searchDateMatches(modUnix int64, opts iteminfo.SearchOptions) bool {
return true
}
// SearchMultiSources searches across multiple sources in a single database query.
// sources is a list of source names to search.
// sourceScopes is a map from source name to the scope path for that source.
// sourceSession is used for cancellation tracking.
// largest and limit work the same as in Search.
func SearchMultiSources(search string, sources []string, sourceScopes map[string]string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64) []*SearchResult {
// SearchMultiSources parses query and delegates to SearchMultiSourcesParsed.
func SearchMultiSources(search string, sources []string, sourceScopes map[string]string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64, useWildcard bool) []*SearchResult {
return SearchMultiSourcesParsed(iteminfo.ParseSearch(search), sources, sourceScopes, sourceSession, largest, limit, olderThanUnix, newerThanUnix, useWildcard)
}
// SearchMultiSourcesParsed searches multiple sources using pre-built options.
func SearchMultiSourcesParsed(baseOpts iteminfo.SearchOptions, sources []string, sourceScopes map[string]string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64, useWildcard bool) []*SearchResult {
if len(sources) == 0 {
return []*SearchResult{}
}
// Get the shared database
db := GetIndexDB()
if db == nil {
return []*SearchResult{}
}
// Ensure all scopes have consistent trailing slash
normalizedScopes := make(map[string]string)
for source, scope := range sourceScopes {
normalizedScopes[source] = utils.AddTrailingSlashIfNotExists(scope)
@@ -184,29 +218,32 @@ func SearchMultiSources(search string, sources []string, sourceScopes map[string
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash)
searchOptions := iteminfo.ParseSearch(search)
searchOptions := baseOpts
if olderThanUnix > 0 {
searchOptions.ModifiedOlderThan = olderThanUnix
}
if newerThanUnix > 0 {
searchOptions.ModifiedNewerThan = newerThanUnix
}
results := make(map[string]*SearchResult, 0)
results := make(map[string]*SearchResult)
count := 0
// When largest=true and no search terms, ensure we run at least once
if largest && len(searchOptions.Terms) == 0 {
searchOptions.Terms = []string{""}
}
rows, err := db.SearchItemsMultiSource(sources, normalizedScopes, largest)
nameGlobPatterns := nameGlobPatternsForSearch(searchOptions, useWildcard, largest)
globAnd := useWildcard && searchOptions.MatchAllTerms
rows, err := db.SearchItemsMultiSource(sources, normalizedScopes, largest, nameGlobPatterns, globAnd)
if err != nil {
return []*SearchResult{}
}
defer rows.Close()
for rows.Next() {
// Check for cancellation
value, found := sessionInProgress.Load(sourceSession)
if !found || value != runningHash {
return []*SearchResult{}
@@ -230,7 +267,6 @@ func SearchMultiSources(search string, sources []string, sourceScopes map[string
continue
}
// Create ItemInfo for matching
item := iteminfo.ItemInfo{
Name: name,
Size: size,
@@ -239,63 +275,31 @@ func SearchMultiSources(search string, sources []string, sourceScopes map[string
HasPreview: hasPreview,
}
// Check against all search terms
for _, searchTerm := range searchOptions.Terms {
if searchTerm == "" && !largest {
continue
matches := itemMatchesSearchFilters(item, isDir, searchOptions, largest, useWildcard, nameGlobPatterns)
if matches {
resType := mimeType
if isDir {
resType = "directory"
}
var matches bool
if largest {
// When largest=true, check size and type conditions, skip name matching
largerThan := int64(searchOptions.LargerThan) * 1024 * 1024
sizeMatches := largerThan == 0 || item.Size > largerThan
// Check if directories should be excluded (when type:file is specified)
dirCondition, hasDirCondition := searchOptions.Conditions["dir"]
// For directories: match if dir condition is explicitly true
// For files: match if no dir condition, or if dir condition is false
var typeMatches bool
if isDir {
typeMatches = hasDirCondition && dirCondition
} else {
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
}
dateMatches := searchDateMatches(item.ModTime.Unix(), searchOptions)
matches = sizeMatches && typeMatches && dateMatches
} else {
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
}
if matches {
// Determine type string for response
resType := mimeType
if isDir {
resType = "directory"
}
// Use source+path as key to handle same path in different sources
key := source + ":" + path
results[key] = &SearchResult{
Path: path,
Type: resType,
Size: size,
Modified: item.ModTime.Format(time.RFC3339),
HasPreview: hasPreview,
Source: source,
}
count++
// Break inner loop (terms) if matched, move to next row
break
key := source + ":" + path
results[key] = &SearchResult{
Path: path,
Type: resType,
Size: size,
Modified: item.ModTime.Format(time.RFC3339),
HasPreview: hasPreview,
Source: source,
}
count++
}
}
// Sort keys based on the number of elements in the path after splitting by "/"
sortedKeys := make([]*SearchResult, 0, len(results))
for _, v := range results {
sortedKeys = append(sortedKeys, v)
}
// Sort the strings based on the number of elements after splitting by "/"
sort.Slice(sortedKeys, func(i, j int) bool {
parts1 := strings.Split(sortedKeys[i].Path, "/")
parts2 := strings.Split(sortedKeys[j].Path, "/")
+19 -5
View File
@@ -62,7 +62,7 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
for i := 0; i < b.N; i++ {
// Execute the SearchAllIndexes function
for _, term := range searchTerms {
idx.Search(term, "/", "test", false, DefaultSearchResults, 0, 0)
idx.Search(term, "/", "test", false, DefaultSearchResults, 0, 0, false)
}
}
}
@@ -86,6 +86,20 @@ func TestParseSearch(t *testing.T) {
Terms: []string{"my", "test", "search"},
},
},
{
input: "house | *car",
want: iteminfo.SearchOptions{
Conditions: map[string]bool{"exact": false},
Terms: []string{"house", "*car"},
},
},
{
input: "a||b",
want: iteminfo.SearchOptions{
Conditions: map[string]bool{"exact": false},
Terms: []string{"a", "b"},
},
},
{
input: "type:largerThan=100 type:smallerThan=1000 test",
want: iteminfo.SearchOptions{
@@ -174,7 +188,7 @@ func TestSearchWhileIndexing(t *testing.T) {
wg.Add(1)
go func(searchTerm string) {
defer wg.Done()
idx.Search(searchTerm, "/", "test", false, DefaultSearchResults, 0, 0)
idx.Search(searchTerm, "/", "test", false, DefaultSearchResults, 0, 0, false)
}(term)
}
@@ -452,7 +466,7 @@ func TestSearchIndexes(t *testing.T) {
for _, tt := range tests {
t.Run(tt.search, func(t *testing.T) {
result := index.Search(tt.search, tt.scope, "", false, DefaultSearchResults, 0, 0)
result := index.Search(tt.search, tt.scope, "", false, DefaultSearchResults, 0, 0, false)
// Convert results to comparable format (without Modified field)
expected := make([]SearchResult, len(tt.expectedResult))
for i, r := range tt.expectedResult {
@@ -544,7 +558,7 @@ func TestSearchLargestModeExcludesRoot(t *testing.T) {
}
// Test that when largest=true and scope="/", the root directory "/" is NOT included
result := index.Search("", "/", "test-session", true, DefaultSearchResults, 0, 0)
result := index.Search("", "/", "test-session", true, DefaultSearchResults, 0, 0, false)
// Verify that "/" is NOT in the results
rootFound := false
@@ -640,7 +654,7 @@ func TestSearchLargestModeExcludesScopeDirectory(t *testing.T) {
}
// Test that when largest=true and scope="/test/", the scope directory "/test/" is NOT included
result := index.Search("", "/test/", "test-session", true, DefaultSearchResults, 0, 0)
result := index.Search("", "/test/", "test-session", true, DefaultSearchResults, 0, 0, false)
// Verify that "/test/" is NOT in the results
scopeDirFound := false
+2 -1
View File
@@ -12,7 +12,8 @@ input,
#search-input,
.floating-tooltip,
.settings-items>.item,
.button-group {
.button-group,
.border-radius {
border-radius: 0.25em !important;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+621 -84
View File
@@ -176,6 +176,9 @@ definitions:
type: boolean
hash:
type: string
hideFileExt:
description: show hidden files based on extensions in shares
type: string
hideNavButtons:
type: boolean
keepAfterExpiration:
@@ -514,6 +517,14 @@ definitions:
description: type of the file, either "directory" or a file mimetype
type: string
type: object
iteminfo.Lyric:
properties:
text:
type: string
timestamp:
description: milliseconds
type: integer
type: object
iteminfo.MediaMetadata:
properties:
album:
@@ -533,6 +544,14 @@ definitions:
genre:
description: music/video genre
type: string
hasLyrics:
description: checks if lyrics are available without parse them
type: boolean
lyrics:
description: lyrics (from embedded tags or .lrc files)
items:
$ref: '#/definitions/iteminfo.Lyric'
type: array
title:
description: track/video title
type: string
@@ -867,6 +886,10 @@ definitions:
type: object
settings.LogConfig:
properties:
apiFilter:
description: regex filter that excludes matching full api paths from being
logged. (eg. '/user\?id\=self') Defaults to '^/health|^/favicon.ico|^/static|^/public/static'
type: string
apiLevels:
description: separated list of log levels to enable for the API. (eg. "info|warning|error")
type: string
@@ -883,7 +906,7 @@ definitions:
description: output location. (eg. "stdout" or "path/to/file.log")
type: string
utc:
description: use UTC time in the output instead of local time
description: use UTC time in the output instead of local time.
type: boolean
type: object
settings.LoginMethods:
@@ -898,6 +921,8 @@ definitions:
type: boolean
oidc:
$ref: '#/definitions/settings.OidcConfig'
passkey:
$ref: '#/definitions/settings.PasskeyAuthConfig'
password:
$ref: '#/definitions/settings.PasswordAuthConfig'
proxy:
@@ -989,6 +1014,26 @@ definitions:
- secret
- url
type: object
settings.PasskeyAuthConfig:
properties:
enabled:
description: whether to enable passkey MFA
type: boolean
loginButtonText:
description: custom text for the passkey login button
type: string
rpDisplayName:
description: the Relying Party display name (defaults to frontend name)
type: string
rpId:
description: the Relying Party ID; if empty, auto-derived from base URL host
type: string
rpOrigins:
description: allowed origins; if empty, auto-derived from base URL
items:
type: string
type: array
type: object
settings.PasswordAuthConfig:
properties:
enabled:
@@ -1238,69 +1283,133 @@ definitions:
type: object
settings.UserDefaults:
properties:
account:
$ref: '#/definitions/settings.UserDefaultsAccount'
customTheme:
description: Name of theme to use chosen from custom themes config.
description: 'deprecated: use ui.customTheme instead'
type: string
darkMode:
description: should dark mode be enabled
description: 'deprecated: use sidebar.darkMode instead'
type: boolean
dateFormat:
description: when false, the date is relative, when true, the date is an exact
timestamp
description: 'deprecated: use listing.dateFormat instead'
type: boolean
debugOffice:
description: debug onlyoffice editor
description: 'deprecated: use fileViewer.debugOffice instead'
type: boolean
deleteAfterArchive:
description: delete source files after successful creation/extraction of archives
description: 'deprecated: use listing.deleteAfterArchive instead'
type: boolean
deleteWithoutConfirming:
description: delete files without confirmation
description: 'deprecated: use listing.deleteWithoutConfirming instead'
type: boolean
disableOfficePreviewExt:
description: 'deprecated: use disablePreviewExt instead'
type: string
disableOnlyOfficeExt:
description: list of file extensions to disable onlyoffice editor for
description: 'deprecated: use fileViewer.disableOnlyOfficeExt instead'
type: string
disablePreviewExt:
description: space separated list of file extensions to disable preview for
description: 'deprecated: use preview.disablePreviewExt instead'
type: string
disableQuickToggles:
description: disable the quick toggles in the sidebar
description: 'deprecated: use sidebar.disableQuickToggles instead'
type: boolean
disableSearchOptions:
description: disable the search options in the search bar
description: 'deprecated: use search.disableOptions instead'
type: boolean
disableSettings:
description: 'deprecated: use account.disableSettings instead'
type: boolean
disableUpdateNotifications:
description: 'deprecated: use account.disableUpdateNotifications instead'
type: boolean
disableViewingExt:
description: 'deprecated: use fileViewer.disableViewingExt instead'
type: string
editorQuickSave:
description: Deprecated fields - kept for backwards compatibility
type: boolean
fileLoading:
$ref: '#/definitions/users.FileLoading'
fileViewer:
$ref: '#/definitions/settings.UserDefaultsFileViewer'
gallerySize:
description: 'deprecated: use sidebar.gallerySize instead'
type: integer
hideFileExt:
description: 'deprecated: use listing.hideFileExt instead'
type: string
hideFilesInTree:
description: 'deprecated: use sidebar.hideFilesInTree instead'
type: boolean
hideSidebarFileActions:
description: 'deprecated: use sidebar.hideSidebarFileActions instead'
type: boolean
listing:
$ref: '#/definitions/settings.UserDefaultsListing'
locale:
description: 'deprecated: use ui.locale instead'
type: string
lockPassword:
description: 'deprecated: use account.lockPassword instead'
type: boolean
loginMethod:
description: 'deprecated: use account.loginMethod instead'
type: string
permissions:
allOf:
- $ref: '#/definitions/settings.UserDefaultsPermissions'
description: 'deprecated: use account.permissions instead'
preferEditorForMarkdown:
description: 'deprecated: use fileViewer.preferEditorForMarkdown instead'
type: boolean
preview:
$ref: '#/definitions/settings.UserDefaultsPreview'
quickDownload:
description: 'deprecated: use listing.quickDownload instead'
type: boolean
search:
$ref: '#/definitions/settings.UserDefaultsSearch'
showCopyPath:
description: 'deprecated: use listing.showCopyPath instead'
type: boolean
showHidden:
description: 'deprecated: use listing.showHidden instead'
type: boolean
showSelectMultiple:
description: 'deprecated: use listing.showSelectMultiple instead'
type: boolean
showToolsInSidebar:
description: 'deprecated: use sidebar.showToolsInSidebar instead'
type: boolean
sidebar:
allOf:
- $ref: '#/definitions/settings.UserDefaultsSidebar'
description: New organized structure
singleClick:
description: 'deprecated: use sidebar.singleClick instead'
type: boolean
stickySidebar:
description: 'deprecated: use sidebar.stickySidebar instead'
type: boolean
themeColor:
description: 'deprecated: use ui.themeColor instead'
type: string
ui:
$ref: '#/definitions/settings.UserDefaultsUI'
viewMode:
description: 'deprecated: use sidebar.viewMode instead'
type: string
type: object
settings.UserDefaultsAccount:
properties:
disableSettings:
description: disable the user from viewing the settings page
type: boolean
disableUpdateNotifications:
description: disable update notifications banner for admin users
type: boolean
disableViewingExt:
description: space separated list of file extensions to disable viewing for
type: string
editorQuickSave:
description: show quick save button in editor
type: boolean
fileLoading:
allOf:
- $ref: '#/definitions/users.FileLoading'
description: upload and download settings
gallerySize:
description: 0-9 - the size of the gallery thumbnails
type: integer
hideFilesInTree:
description: hide files in the sidebar tree navigation, when true, will show
only directories.
type: boolean
hideSidebarFileActions:
description: hide the file actions in the sidebar
type: boolean
locale:
description: 'language to use: eg. de, en, or fr'
type: string
lockPassword:
description: disable the user from changing their password
type: boolean
@@ -1308,39 +1417,9 @@ definitions:
description: 'login method to use: eg. password, proxy, oidc'
type: string
permissions:
$ref: '#/definitions/settings.UserDefaultsPermissions'
preferEditorForMarkdown:
description: prefer editor first for markdown files instead of the Markdown
Viewer.
type: boolean
preview:
$ref: '#/definitions/settings.UserDefaultsPreview'
quickDownload:
description: show icon to download in one click
type: boolean
showHidden:
description: show hidden files in the UI. On windows this includes files starting
with a dot and windows hidden files
type: boolean
showSelectMultiple:
description: show select multiple files on desktop
type: boolean
singleClick:
description: open directory on single click, also enables middle click to
open in new tab
type: boolean
stickySidebar:
description: keep sidebar open when navigating
type: boolean
themeColor:
description: 'theme color to use: eg. #ff0000, or var(--red), var(--purple),
etc'
type: string
viewMode:
description: 'view mode to use: eg. normal, list, grid, or compact'
type: string
$ref: '#/definitions/settings.UserDefaultsAccountPermissions'
type: object
settings.UserDefaultsPermissions:
settings.UserDefaultsAccountPermissions:
properties:
admin:
description: allow admin access
@@ -1367,25 +1446,128 @@ definitions:
description: allow sharing files
type: boolean
type: object
settings.UserDefaultsFileViewer:
properties:
autoplayMedia:
description: autoplay media files in preview
type: boolean
debugOffice:
description: debug onlyoffice editor
type: boolean
defaultMediaPlayer:
description: disable the styled feature-rich media player for browser default
type: boolean
disableOnlyOfficeExt:
description: list of file extensions to disable onlyoffice editor for
type: string
disableViewingExt:
description: comma separated list of file extensions to disable viewing for
type: string
editorQuickSave:
description: show quick save button in editor
type: boolean
preferEditorForMarkdown:
description: prefer editor first for markdown files instead of the Markdown
Viewer
type: boolean
type: object
settings.UserDefaultsListing:
properties:
dateFormat:
description: when false, the date is relative, when true, the date is an exact
timestamp
type: boolean
deleteAfterArchive:
description: delete source files after successful creation/extraction of archives
type: boolean
deleteWithoutConfirming:
description: delete files without confirmation
type: boolean
gallerySize:
description: 0-9 - the size of the gallery thumbnails
type: integer
hideFileExt:
description: space separated list of file extensions to hide in UI
type: string
quickDownload:
description: show icon to download in one click
type: boolean
showCopyPath:
description: show copy path button in the context menu
type: boolean
showHidden:
description: show hidden files in the UI. On windows this includes files starting
with a dot and windows hidden files
type: boolean
showSelectMultiple:
description: show select multiple files on desktop
type: boolean
singleClick:
description: open directory on single click, also enables middle click to
open in new tab
type: boolean
viewMode:
description: 'view mode to use: eg. normal, list, grid, or compact'
type: string
type: object
settings.UserDefaultsPermissions:
properties:
admin:
description: 'deprecated: use account.permissions.admin instead. allow admin
access'
type: boolean
api:
description: 'deprecated: use account.permissions.api instead. allow api access'
type: boolean
create:
description: 'deprecated: use account.permissions.create instead. allow creating
or uploading files'
type: boolean
delete:
description: 'deprecated: use account.permissions.delete instead. allow deleting
files'
type: boolean
download:
description: 'deprecated: use account.permissions.download instead. allow
downloading files'
type: boolean
modify:
description: 'deprecated: use account.permissions.modify instead. allow modifying
files'
type: boolean
realtime:
description: 'deprecated: use account.permissions.realtime instead. allow
realtime updates'
type: boolean
share:
description: 'deprecated: use account.permissions.share instead. allow sharing
files'
type: boolean
type: object
settings.UserDefaultsPreview:
properties:
audio:
description: show thumbnails for audio files
type: boolean
autoplayMedia:
description: autoplay media files in preview
description: 'deprecated: use fileViewer.autoplayMedia instead. autoplay media
files in preview'
type: boolean
defaultMediaPlayer:
description: disable the styled feature-rich media player for browser default
description: 'deprecated: use fileViewer.defaultMediaPlayer instead. disable
the styled feature-rich media player for browser default'
type: boolean
disableHideSidebar:
description: keep sidebar open when previewing files
description: deprecated fields
type: boolean
disablePreviewExt:
description: comma separated list of file extensions to disable preview for
type: string
folder:
description: show thumbnails for folders that have previewable contents
type: boolean
highQuality:
description: 'deprecated: always true in v1.3.0+'
description: high quality preview thumbnails
type: boolean
image:
description: show thumbnails for image files
@@ -1406,6 +1588,50 @@ definitions:
description: show thumbnails for video files
type: boolean
type: object
settings.UserDefaultsSearch:
properties:
disableOptions:
description: disable the search options in the search bar
type: boolean
type: object
settings.UserDefaultsSidebar:
properties:
disableHideOnPreview:
description: keep sidebar open when previewing files (was preview.disableHideSidebar)
type: boolean
disableQuickToggles:
description: disable the quick toggles in the sidebar
type: boolean
hideFileActions:
description: hide the file actions in the sidebar
type: boolean
hideFiles:
description: hide files in the sidebar tree navigation, when true, will show
only directories
type: boolean
showTools:
description: show sidebar links with category "tool"; default is true
type: boolean
sticky:
description: keep sidebar open when navigating
type: boolean
type: object
settings.UserDefaultsUI:
properties:
customTheme:
description: Name of theme to use chosen from custom themes config
type: string
darkMode:
description: should dark mode be enabled
type: boolean
locale:
description: 'language to use: eg. de, en, or fr'
type: string
themeColor:
description: 'theme color to use: eg. #ff0000, or var(--red), var(--purple),
etc'
type: string
type: object
share.CommonShare:
properties:
allowCreate:
@@ -1466,6 +1692,9 @@ definitions:
type: string
hasPassword:
type: boolean
hideFileExt:
description: show hidden files based on extensions in shares
type: string
hideNavButtons:
type: boolean
keepAfterExpiration:
@@ -1571,6 +1800,9 @@ definitions:
type: boolean
hash:
type: string
hideFileExt:
description: show hidden files based on extensions in shares
type: string
hideNavButtons:
type: boolean
keepAfterExpiration:
@@ -1682,6 +1914,9 @@ definitions:
type: boolean
hash:
type: string
hideFileExt:
description: show hidden files based on extensions in shares
type: string
hideNavButtons:
type: boolean
keepAfterExpiration:
@@ -1859,7 +2094,7 @@ definitions:
description: Display name of the link
type: string
sourceName:
description: Source identifier for source-type links
description: 'Bolt: filesystem path. JSON out: display name (after prepForFrontend).'
type: string
target:
description: Target path/URL for the link (relative for source/share)
@@ -1875,8 +2110,10 @@ definitions:
users.SourceScope:
properties:
name:
description: 'Bolt: filesystem path; JSON API: display name after prepForFrontend'
type: string
scope:
description: index path within that source
type: string
type: object
users.User:
@@ -1938,6 +2175,9 @@ definitions:
gallerySize:
description: 0-9 - the size of the gallery thumbnails
type: integer
hideFileExt:
description: space separated list of file extensions to hide in UI and API
type: string
hideFilesInTree:
description: hide files in the sidebar tree navigation, when true, will show
only directories.
@@ -1957,6 +2197,10 @@ definitions:
otpEnabled:
description: true if TOTP is enabled, false otherwise
type: boolean
passkeyCredentials:
items:
$ref: '#/definitions/users.WebAuthnCredential'
type: array
password:
type: string
perm:
@@ -1992,6 +2236,10 @@ definitions:
showSelectMultiple:
description: show select multiple files on desktop
type: boolean
showToolsInSidebar:
description: 'when false, sidebar hides links with category "tool" (default:
true)'
type: boolean
sidebarLinks:
description: customizable sidebar links
items:
@@ -2026,6 +2274,56 @@ definitions:
description: 'view mode to use: eg. normal, list, grid, or compact'
type: string
type: object
users.WebAuthnCredential:
properties:
aaguid:
type: string
attestationFormat:
type: string
attestationObj:
type: string
attestationType:
type: string
authenticatorData:
type: string
clientDataHash:
type: string
clientDataJSON:
type: string
cloneWarning:
type: boolean
createdAt:
type: integer
flags:
$ref: '#/definitions/users.WebAuthnCredentialFlags'
id:
type: string
lastUsedAt:
type: integer
name:
type: string
publicKey:
type: string
publicKeyAlg:
type: integer
signCount:
type: integer
transport:
items:
type: string
type: array
type: object
users.WebAuthnCredentialFlags:
properties:
backupEligible:
type: boolean
backupState:
type: boolean
userPresent:
type: boolean
userVerified:
type: boolean
type: object
utils.SubtitleTrack:
properties:
codec:
@@ -2764,6 +3062,188 @@ paths:
summary: List API tokens
tags:
- Auth
/api/auth/webauthn/{id}:
delete:
description: Removes a passkey credential by its ID.
parameters:
- description: Base64-encoded credential ID
in: path
name: id
required: true
type: string
responses:
"200":
description: Success
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
summary: Delete a passkey credential
tags:
- Auth
/api/auth/webauthn/begin-login:
post:
consumes:
- application/json
description: Verifies the user's password and returns a WebAuthn assertion challenge
for passkey MFA.
parameters:
- description: Username
in: query
name: username
required: true
type: string
- description: URL-encoded password
in: header
name: X-Password
required: true
type: string
produces:
- application/json
responses:
"200":
description: Passkey assertion options
schema:
additionalProperties: true
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
summary: Begin passkey MFA login
tags:
- Auth
/api/auth/webauthn/begin-register:
post:
consumes:
- application/json
description: Returns WebAuthn credential creation options for adding a new passkey.
produces:
- application/json
responses:
"200":
description: Passkey creation options
schema:
additionalProperties: true
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
summary: Begin passkey registration
tags:
- Auth
/api/auth/webauthn/finish-login:
post:
consumes:
- application/json
description: Verifies the WebAuthn assertion and returns a JWT token.
parameters:
- description: Passkey session ID from begin-login
in: query
name: session_id
required: true
type: string
produces:
- application/json
responses:
"200":
description: JWT token
schema:
type: string
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
summary: Finish passkey MFA login
tags:
- Auth
/api/auth/webauthn/finish-register:
post:
consumes:
- application/json
description: Verifies the attestation and stores the new passkey credential.
parameters:
- description: Registration session ID
in: query
name: session_id
required: true
type: string
- description: Display name for the passkey
in: query
name: name
required: true
type: string
produces:
- application/json
responses:
"200":
description: Success
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
summary: Finish passkey registration
tags:
- Auth
/api/media/lyrics:
get:
consumes:
- application/json
description: Returns parsed lyrics with optional timestamps from embedded tags
or sidecar .lrc files.
parameters:
- description: Path to the directory or file
in: query
name: path
required: true
type: string
- description: Source name
in: query
name: source
required: true
type: string
produces:
- application/json
responses:
"200":
description: Lyrics array
schema:
additionalProperties: true
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
summary: Get lyrics for an audio file
tags:
- Resources
/api/media/metadata:
get:
consumes:
@@ -4076,28 +4556,36 @@ paths:
and metadata based on the user's session and scope. Supports searching across
multiple sources when using the 'sources' parameter.
parameters:
- description: Search query
- description: Filter prefix or full legacy search text (required when no terms
are supplied)
in: query
name: query
required: true
type: string
- collectionFormat: csv
description: 'Repeated: one literal search term per parameter; combined with
OR unless termJoin=and'
in: query
items:
type: string
name: terms
type: array
- description: Source name for the desired source (deprecated, use 'sources'
instead)
in: query
name: source
type: string
- description: 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.
- description: Comma-separated source names when not using repeated scope=source:path
in: query
name: sources
type: string
- description: path within user scope to search, for example '/first/second'
to search within the second directory only. Ignored when multiple sources
are specified.
- collectionFormat: csv
description: 'Repeated: either ''sourceName:relativePath'' per source, or
legacy single path when one source'
in: query
items:
type: string
name: scope
type: string
type: array
- description: Unix seconds; only results modified strictly before this time
in: query
name: olderThan
@@ -4106,9 +4594,23 @@ paths:
in: query
name: newerThan
type: integer
- description: User session ID, add unique value to prevent collisions
in: header
name: SessionId
- description: When true, match indexed file names with SQLite GLOB (wildcard
patterns)
in: query
name: useWildcard
type: boolean
- description: 'Deprecated: alias for useWildcard'
in: query
name: glob
type: boolean
- description: 'Deprecated: alias for useWildcard'
in: query
name: useGlob
type: boolean
- description: 'Optional: ''and'' to require all repeated ''terms'' match; default
is OR'
in: query
name: termJoin
type: string
produces:
- application/json
@@ -4326,6 +4828,41 @@ paths:
schema:
$ref: '#/definitions/http.HttpResponse'
summary: Health Check
/public/api/media/lyrics:
get:
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share
in: query
name: path
type: string
produces:
- application/json
responses:
"200":
description: Lyrics array
schema:
additionalProperties: true
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
summary: Get lyrics for an audio file (public share)
tags:
- Shares
/public/api/media/metadata:
get:
parameters:
+242 -224
View File
@@ -101,9 +101,9 @@
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
@@ -1126,13 +1126,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
@@ -1165,9 +1165,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
"integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
"cpu": [
"arm"
],
@@ -1179,9 +1179,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
"integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
"cpu": [
"arm64"
],
@@ -1193,9 +1193,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
"integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
"cpu": [
"arm64"
],
@@ -1207,9 +1207,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
"integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
"cpu": [
"x64"
],
@@ -1221,9 +1221,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
"integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
"cpu": [
"arm64"
],
@@ -1235,9 +1235,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
"integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
"cpu": [
"x64"
],
@@ -1249,9 +1249,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
"integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
"cpu": [
"arm"
],
@@ -1263,9 +1263,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
"integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
"cpu": [
"arm"
],
@@ -1277,9 +1277,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
"integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
"cpu": [
"arm64"
],
@@ -1291,9 +1291,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
"integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
"cpu": [
"arm64"
],
@@ -1305,9 +1305,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
"integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
"cpu": [
"loong64"
],
@@ -1319,9 +1319,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
"integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
"cpu": [
"loong64"
],
@@ -1333,9 +1333,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
"integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
"cpu": [
"ppc64"
],
@@ -1347,9 +1347,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
"integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
"cpu": [
"ppc64"
],
@@ -1361,9 +1361,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
"integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
"cpu": [
"riscv64"
],
@@ -1375,9 +1375,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
"integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
"cpu": [
"riscv64"
],
@@ -1389,9 +1389,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
"integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
"cpu": [
"s390x"
],
@@ -1403,9 +1403,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
"integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
"cpu": [
"x64"
],
@@ -1417,9 +1417,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
"integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
"cpu": [
"x64"
],
@@ -1431,9 +1431,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
"integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
"cpu": [
"x64"
],
@@ -1445,9 +1445,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
"integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
"cpu": [
"arm64"
],
@@ -1459,9 +1459,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
"integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
"cpu": [
"arm64"
],
@@ -1473,9 +1473,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
"integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
"cpu": [
"ia32"
],
@@ -1487,9 +1487,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
"integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
"cpu": [
"x64"
],
@@ -1501,9 +1501,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
"integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
"cpu": [
"x64"
],
@@ -1533,9 +1533,9 @@
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
@@ -1550,9 +1550,9 @@
}
},
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1770,9 +1770,9 @@
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
"integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
"dev": true,
"license": "ISC"
},
@@ -1959,53 +1959,53 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/shared": "3.5.33",
"@babel/parser": "^7.29.3",
"@vue/shared": "3.5.34",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-core": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
"integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.33",
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33",
"@babel/parser": "^7.29.3",
"@vue/compiler-core": "3.5.34",
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-ssr": "3.5.34",
"@vue/shared": "3.5.34",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.10",
"postcss": "^8.5.14",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
"integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-dom": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/compiler-vue2": {
@@ -2076,53 +2076,53 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.33"
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/reactivity": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.33",
"@vue/runtime-core": "3.5.33",
"@vue/shared": "3.5.33",
"@vue/reactivity": "3.5.34",
"@vue/runtime-core": "3.5.34",
"@vue/shared": "3.5.34",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-ssr": "3.5.34",
"@vue/shared": "3.5.34"
},
"peerDependencies": {
"vue": "3.5.33"
"vue": "3.5.34"
}
},
"node_modules/@vue/shared": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
@@ -2135,9 +2135,9 @@
}
},
"node_modules/ace-builds": {
"version": "1.43.6",
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.6.tgz",
"integrity": "sha512-L1ddibQ7F3vyXR2k2fg+I8TQTPWVA6CKeDQr/h2+8CeyTp3W6EQL8xNFZRTztuP8xNOAqL3IYPqdzs31GCjDvg==",
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.44.0.tgz",
"integrity": "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==",
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
@@ -2163,6 +2163,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adm-zip": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -2257,12 +2267,12 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
@@ -2307,9 +2317,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -2637,19 +2647,20 @@
"license": "MIT"
},
"node_modules/deepl-node": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.16.0.tgz",
"integrity": "sha512-sYrh8UngVHKBrgffLPl+ng065mlC+Ep3LBdSN3xqDBd4KdBZzgvdxHiHd9Pm9xQCI5pwyuCHyq0cDUW1T3vQUg==",
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.27.0.tgz",
"integrity": "sha512-OEiprFdxqr7aS0+gmv8vsZLBLoDarBppG1zkTWUlrAvPoYdwSWKFWjcZgr9XSV3FWlPy8g10WdJjWkxDn9P56w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=12.0",
"adm-zip": "^0.5.16",
"axios": "^1.7.4",
"form-data": "^3.0.0",
"form-data": "^3.0.4",
"loglevel": ">=1.6.2"
},
"engines": {
"node": ">=12.0"
"node": ">=14.17"
}
},
"node_modules/deepl-node/node_modules/form-data": {
@@ -2711,9 +2722,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
"integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -3516,9 +3527,9 @@
}
},
"node_modules/fs-extra": {
"version": "11.3.4",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
"integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==",
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4464,9 +4475,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
@@ -4801,13 +4812,13 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
@@ -4820,9 +4831,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4846,9 +4857,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
@@ -4923,9 +4934,9 @@
}
},
"node_modules/qrcode.vue": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.8.1.tgz",
"integrity": "sha512-kJORXcI5Uml5GtzcfFkchpljFgaXKQB/3ZUt+Hwnw7Y1F7FiA01U2yyO5Vscwv3bVxPkm/KLKXUOXsHbTsYrTw==",
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.9.1.tgz",
"integrity": "sha512-CpHVRz5iveqwRFh+nzzSYV9hPWU6q+YSOKyq5ZievjQIBv4bIIDzajGgtNz/yYSlczjAkYM3GNAQJHwwCukMEQ==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0"
@@ -5048,9 +5059,9 @@
}
},
"node_modules/rollup": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
"integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5064,34 +5075,41 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"@rollup/rollup-android-arm-eabi": "4.60.3",
"@rollup/rollup-android-arm64": "4.60.3",
"@rollup/rollup-darwin-arm64": "4.60.3",
"@rollup/rollup-darwin-x64": "4.60.3",
"@rollup/rollup-freebsd-arm64": "4.60.3",
"@rollup/rollup-freebsd-x64": "4.60.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
"@rollup/rollup-linux-arm-musleabihf": "4.60.3",
"@rollup/rollup-linux-arm64-gnu": "4.60.3",
"@rollup/rollup-linux-arm64-musl": "4.60.3",
"@rollup/rollup-linux-loong64-gnu": "4.60.3",
"@rollup/rollup-linux-loong64-musl": "4.60.3",
"@rollup/rollup-linux-ppc64-gnu": "4.60.3",
"@rollup/rollup-linux-ppc64-musl": "4.60.3",
"@rollup/rollup-linux-riscv64-gnu": "4.60.3",
"@rollup/rollup-linux-riscv64-musl": "4.60.3",
"@rollup/rollup-linux-s390x-gnu": "4.60.3",
"@rollup/rollup-linux-x64-gnu": "4.60.3",
"@rollup/rollup-linux-x64-musl": "4.60.3",
"@rollup/rollup-openbsd-x64": "4.60.3",
"@rollup/rollup-openharmony-arm64": "4.60.3",
"@rollup/rollup-win32-arm64-msvc": "4.60.3",
"@rollup/rollup-win32-ia32-msvc": "4.60.3",
"@rollup/rollup-win32-x64-gnu": "4.60.3",
"@rollup/rollup-win32-x64-msvc": "4.60.3",
"fsevents": "~2.3.2"
}
},
"node_modules/rollup/node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/rrweb-cssom": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
@@ -5150,9 +5168,9 @@
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -5656,9 +5674,9 @@
}
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"dev": true,
"license": "MIT"
},
@@ -6047,16 +6065,16 @@
"license": "MIT"
},
"node_modules/vue": {
"version": "3.5.33",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-sfc": "3.5.33",
"@vue/runtime-dom": "3.5.33",
"@vue/server-renderer": "3.5.33",
"@vue/shared": "3.5.33"
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-sfc": "3.5.34",
"@vue/runtime-dom": "3.5.34",
"@vue/server-renderer": "3.5.34",
"@vue/shared": "3.5.34"
},
"peerDependencies": {
"typescript": "*"
@@ -6399,9 +6417,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6447,9 +6465,9 @@
"license": "MIT"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
+59 -42
View File
@@ -17,7 +17,8 @@ server:
output: "stdout" # output location. (eg. "stdout" or "path/to/file.log")
noColors: false # disable colors in the output
json: false # output in json format
utc: false # use UTC time in the output instead of local time
utc: false # use UTC time in the output instead of local time.
apiFilter: "" # regex filter that excludes matching full api paths from being logged. (eg. '/user\?id\=self') Defaults to '^/health|^/favicon.ico|^/static|^/public/static'
database: "database.db" # path to the database file
sources: # validate:required,dive
- path: "/relative/or/absolute/path" # file system path. (Can be relative) validate:required
@@ -118,6 +119,12 @@ auth:
header: "" # HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
secret: "" # secret: Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
algorithm: "" # JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
passkey: # validate:omitempty
enabled: false # whether to enable passkey MFA
rpDisplayName: "" # the Relying Party display name (defaults to frontend name)
rpId: "" # the Relying Party ID; if empty, auto-derived from base URL host
rpOrigins: # allowed origins; if empty, auto-derived from base URL
loginButtonText: "" # custom text for the passkey login button
key: "" # secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
adminUsername: "admin" # secret: the username of the admin user. If not set, the default is "admin".
adminPassword: "admin" # secret: the password of the admin user. If not set, the default is "admin".
@@ -152,60 +159,70 @@ frontend:
loginButtonText: "" # text to display on the login button
oidcLoginButtonText: "" # text to display on the OIDC login button
userDefaults:
editorQuickSave: false # show quick save button in editor
hideSidebarFileActions: false # hide the file actions in the sidebar
disableQuickToggles: false # disable the quick toggles in the sidebar
disableSearchOptions: false # disable the search options in the search bar
stickySidebar: true # keep sidebar open when navigating
hideFilesInTree: false # hide files in the sidebar tree navigation, when true, will show only directories.
darkMode: true # should dark mode be enabled
locale: "en" # language to use: eg. de, en, or fr
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
singleClick: false # open directory on single click, also enables middle click to open in new tab
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
quickDownload: false # show icon to download in one click
disablePreviewExt: "" # space separated list of file extensions to disable preview for
disableViewingExt: "" # space separated list of file extensions to disable viewing for
lockPassword: false # disable the user from changing their password
disableSettings: false # disable the user from viewing the settings page
sidebar: # New organized structure
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 (was preview.disableHideSidebar)
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 with category "tool"; default is true
listing:
deleteWithoutConfirming: false # delete files without confirmation
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
quickDownload: false # show icon to download in one click
showSelectMultiple: false # show select multiple files on desktop
singleClick: false # open directory on single click, also enables middle click to open in new tab
hideFileExt: "" # space separated list of file extensions to hide in UI
showCopyPath: false # show copy path button in the context menu
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
preview:
disableHideSidebar: false # keep sidebar open when previewing files
image: true # show thumbnails for image files
video: true # show thumbnails for video files
audio: true # show thumbnails for audio files
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
office: true # show thumbnails for office files
popup: true # show larger popup preview when hovering over thumbnail
autoplayMedia: true # autoplay media files in preview
defaultMediaPlayer: false # disable the styled feature-rich media player for browser default
disablePreviewExt: "" # comma separated list of file extensions to disable preview for
highQuality: true # high quality preview thumbnails
folder: true # show thumbnails for folders that have previewable contents
models: true # show live thumbnails for 3D models files
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
loginMethod: "password" # login method to use: eg. password, proxy, oidc
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
fileLoading: # upload and download settings
fileViewer:
editorQuickSave: false # show quick save button in editor
autoplayMedia: true # autoplay media files in preview
disableViewingExt: "" # comma 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 the styled feature-rich media player for browser default
search:
disableOptions: false # disable the search options in the search bar
ui:
darkMode: true # should dark mode be enabled
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
uploadChunkSizeMb: 10
clearAll: false
downloadChunkSizeMb: 0
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.
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
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
integrations:
office: # validate:omitempty
url: "" # The URL to the OnlyOffice Document Server, needs to be accessible to the user. validate:required
+146
View File
@@ -182,3 +182,149 @@ export function deleteApiKey(params) {
throw err
}
}
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
bytes.forEach(b => binary += String.fromCharCode(b))
return btoa(binary).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}
function base64ToArrayBuffer(base64url) {
// Convert URL-safe base64 to standard base64
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
while (base64.length % 4) base64 += '='
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function preprocessPublicKeyOptions(options) {
const opts = { ...options }
if (typeof opts.challenge === 'string') {
opts.challenge = base64ToArrayBuffer(opts.challenge)
}
if (opts.user && typeof opts.user.id === 'string') {
opts.user = { ...opts.user, id: base64ToArrayBuffer(opts.user.id) }
}
if (opts.allowCredentials && Array.isArray(opts.allowCredentials)) {
opts.allowCredentials = opts.allowCredentials.map(c => ({
...c,
id: typeof c.id === 'string' ? base64ToArrayBuffer(c.id) : c.id,
}))
}
if (opts.excludeCredentials && Array.isArray(opts.excludeCredentials)) {
opts.excludeCredentials = opts.excludeCredentials.map(c => ({
...c,
id: typeof c.id === 'string' ? base64ToArrayBuffer(c.id) : c.id,
}))
}
return opts
}
function formatCredentialResponse(cred) {
return {
id: cred.id,
rawId: arrayBufferToBase64(cred.rawId),
type: cred.type,
response: {
clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON),
authenticatorData: arrayBufferToBase64(cred.response.authenticatorData),
signature: arrayBufferToBase64(cred.response.signature),
userHandle: cred.response.userHandle ? arrayBufferToBase64(cred.response.userHandle) : null,
},
}
}
function formatAttestationResponse(cred) {
return {
id: cred.id,
rawId: arrayBufferToBase64(cred.rawId),
type: cred.type,
response: {
clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON),
attestationObject: arrayBufferToBase64(cred.response.attestationObject),
transports: cred.response.getTransports ? cred.response.getTransports() : [],
},
}
}
export async function beginPasskeyLogin(username, password) {
const apiPath = getApiPath('auth/webauthn/begin-login', { username })
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-Password': encodeURIComponent(password),
},
})
const body = await res.json().catch(() => ({}))
if (res.status !== 200) {
const msg = body.message || 'Passkey login failed'
throw new Error(msg)
}
const credential = await navigator.credentials.get({ publicKey: preprocessPublicKeyOptions(body.publicKey) })
const formatted = formatCredentialResponse(credential)
const finishPath = getApiPath('auth/webauthn/finish-login', { session_id: body.sessionID })
const finishRes = await fetch(finishPath, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formatted),
})
if (finishRes.status !== 200) {
const errBody = await finishRes.json().catch(() => ({}))
throw new Error(errBody.message || 'Passkey login failed')
}
}
export async function beginPasskeyRegistration() {
const apiPath = getApiPath('auth/webauthn/begin-register')
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
})
const body = await res.json().catch(() => ({}))
if (res.status !== 200) {
const msg = body.message || 'Failed to begin passkey registration'
throw new Error(msg)
}
const credential = await navigator.credentials.create({ publicKey: preprocessPublicKeyOptions(body.publicKey) })
const formatted = formatAttestationResponse(credential)
const name = prompt('Name for this passkey (e.g. "iPhone", "YubiKey"):') || 'Passkey'
const finishPath = getApiPath('auth/webauthn/finish-register', { session_id: body.sessionID, name })
const finishRes = await fetch(finishPath, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formatted),
})
if (finishRes.status !== 200) {
const errBody = await finishRes.json().catch(() => ({}))
throw new Error(errBody.message || 'Failed to register passkey')
}
}
export async function deletePasskeyCredential(credentialId) {
const apiPath = getApiPath(`auth/webauthn/${encodeURIComponent(credentialId)}`)
const res = await fetch(apiPath, {
method: 'DELETE',
credentials: 'same-origin',
})
if (res.status !== 200) {
const errBody = await res.json().catch(() => ({}))
throw new Error(errBody.message || 'Failed to delete passkey')
}
}
+36
View File
@@ -21,6 +21,42 @@ export async function getSubtitleContent(source, path, subtitleName, embedded =
}
}
// GET /api/media/lyrics
export async function getLyrics(source, path) {
const apiPath = getApiPath('media/lyrics', {
source: source,
path: path,
});
const res = await fetchURL(apiPath);
const data = await res.json();
return data.lyrics || [];
}
// GET /public/api/media/lyrics
export async function getLyricsPublic(path, hash, password = "") {
const params = {
path,
hash,
...(state.shareInfo.token && { token: state.shareInfo.token }),
};
const apiPath = getPublicApiPath("media/lyrics", params);
const response = await fetch(apiPath, {
headers: { "X-SHARE-PASSWORD": password || "" },
});
if (!response.ok) {
const error = new Error(response.statusText);
let data = null;
data = await response.json();
if (data?.message) {
error.message = data.message;
}
error.status = response.status;
throw error;
}
const data = await response.json();
return data.lyrics || [];
}
// GET /api/media/metadata — directory or file with metadata; optional albumArt for embedded cover extraction.
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
/** @returns {Promise<object>} resource (adjustedData) */
+54 -13
View File
@@ -3,22 +3,56 @@ import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
// GET /api/tools/search
// extraParams: optional { olderThan, newerThan } — Unix timestamps in seconds (number or numeric string); modified time must be strictly before olderThan / on or after newerThan
// extraParams: optional { olderThan, newerThan, useWildcard, terms, termJoin, perSourceScopes }
// When perSourceScopes is a non-empty array of { source, path }, sends repeated scope=source:path and omits sources (multi-scope API).
// Otherwise legacy: sources + optional scope path for single source.
export async function search(base, sources, query, largest = false, extraParams = {}) {
try {
const sourcesArray = Array.isArray(sources) ? sources : [sources];
const params = {
query: query,
sources: sourcesArray.join(",")
};
const params = {};
// Only include scope if searching a single source
if (sourcesArray.length === 1 && base) {
if (!base.endsWith("/")) {
base += "/";
const rawTerms = extraParams.terms;
const terms =
Array.isArray(rawTerms) ?
rawTerms.map((t) => String(t).trim()).filter((t) => t !== "") :
[];
const prefixQuery = query === undefined || query === null ? "" : String(query).trim();
if (terms.length > 0) {
params.query = prefixQuery;
params.terms = terms;
if (extraParams.termJoin === "and") {
params.termJoin = "and";
}
} else {
params.query = prefixQuery;
}
const perSource = extraParams.perSourceScopes;
if (Array.isArray(perSource) && perSource.length > 0) {
params.scope = perSource.map(({ source, path }) => {
const sourceName = String(source || "").trim();
let scopedPath =
path === undefined || path === null ? "/" : String(path).trim();
if (scopedPath === "") {
scopedPath = "/";
}
if (!scopedPath.startsWith("/")) {
scopedPath = `/${scopedPath}`;
}
return `${sourceName}:${scopedPath}`;
});
} else {
const sourcesArray = Array.isArray(sources) ? sources : [sources];
params.sources = sourcesArray.join(",");
if (sourcesArray.length === 1 && base) {
let scopeBase = base;
if (!scopeBase.endsWith("/")) {
scopeBase += "/";
}
params.scope = scopeBase;
}
params.scope = base;
}
if (largest) {
@@ -31,12 +65,19 @@ export async function search(base, sources, query, largest = false, extraParams
if (extraParams.newerThan !== undefined && extraParams.newerThan !== "") {
params.newerThan = String(extraParams.newerThan);
}
if (
extraParams.useWildcard === true ||
extraParams.useGlob === true ||
extraParams.glob === true
) {
params.useWildcard = "true";
}
const apiPath = getApiPath("tools/search", params);
const res = await fetchURL(apiPath);
let data = await res.json();
return data
return data;
} catch (err) {
notify.showError(err.message || "Error occurred during search");
throw err;
+161 -80
View File
@@ -31,24 +31,37 @@
<div id="result-list">
<div v-if="!disableSearchOptions">
<div v-if="active">
<div v-if="isMobile">
<ButtonGroup :buttons="toggleOptionButton" @button-clicked="enableOptions"
@remove-button-clicked="disableOptions" />
</div>
<transition name="expand">
<div v-show="showOptions" class="search-options">
<!-- Button groups for filtering search results -->
<ButtonGroup :buttons="folderSelect" @button-clicked="addToTypes"
@remove-button-clicked="removeFromTypes" @disableAll="folderSelectClicked()"
@enableAll="resetButtonGroups()" />
<ButtonGroup :buttons="typeSelect" @button-clicked="addToTypes" @remove-button-clicked="removeFromTypes"
:isDisabled="isTypeSelectDisabled" />
<!-- Inputs for filtering by file size -->
<SettingsItem
class="search-options-settings-item"
:title="advancedOptionsExpanded ? $t('buttons.showLess') : $t('buttons.showMore')"
:collapsable="true"
:start-collapsed="!advancedOptionsExpanded"
@toggle="advancedOptionsExpanded = $event"
>
<div class="search-options-inner">
<ButtonGroup
:buttons="folderSelect"
@button-clicked="addToTypes"
@remove-button-clicked="removeFromTypes"
@disable-all="folderSelectClicked()"
@enable-all="resetButtonGroups()"
/>
<ButtonGroup
:buttons="typeSelect"
@button-clicked="addToTypes"
@remove-button-clicked="removeFromTypes"
:isDisabled="isTypeSelectDisabled"
/>
<div class="constraints">
<div class="sizeInputWrapper">
<p>{{ $t("search.smallerThan") }}</p>
<input class="sizeInput" v-model="smallerThan" type="number" min="0"
placeholder="MB" />
<input
class="sizeInput"
v-model="smallerThan"
type="number"
min="0"
placeholder="MB"
/>
<p>{{ $t("search.largerThan") }}</p>
<input class="sizeInput" v-model="largerThan" type="number" placeholder="MB" />
</div>
@@ -59,13 +72,22 @@
<input class="sizeInput" v-model="modifiedNewerThan" type="date" />
</div>
</div>
<!-- Toggle for showing preview images -->
<div class="search-option-item">
<ToggleSwitch v-model="showPreviewImages" :name="$t('search.showPreviewImages')"
:description="$t('search.showPreviewImagesDescription')" />
<div class="settings-items">
<ToggleSwitch
class="item"
v-model="showPreviewImages"
:name="$t('search.showPreviewImages')"
:description="$t('search.showPreviewImagesDescription')"
/>
<ToggleSwitch
class="item"
v-model="useWildcardSearch"
:name="$t('search.useWildcardSearch')"
:description="$t('search.useWildcardSearchDescription')"
/>
</div>
</div>
</transition>
</SettingsItem>
</div>
</div>
<!-- Loading icon when search is ongoing -->
@@ -95,6 +117,13 @@
</a>
</li>
</ul>
<button
type="button"
class="button open-advanced-search-button"
@click.stop.prevent="openInAdvancedSearch"
>
{{ $t("search.openInAdvancedSearch") }}
</button>
</div>
</div>
</div>
@@ -108,8 +137,11 @@ import { getHumanReadableFilesize } from "@/utils/filesizes";
import { url } from "@/utils/";
import Icon from "@/components/files/Icon.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import SettingsItem from "@/components/settings/SettingsItem.vue";
import { globalVars } from "@/utils/constants";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { utcStartOfDaySecondsFromDateInput } from "@/utils/moment";
import router from "@/router";
var boxes = {
folder: { label: "folders", icon: "folder" },
@@ -126,6 +158,7 @@ export default {
ButtonGroup,
Icon,
ToggleSwitch,
SettingsItem,
LoadingSpinner,
},
name: "search",
@@ -139,6 +172,7 @@ export default {
searchTypes: "",
isTypeSelectDisabled: false,
showPreviewImages: false,
useWildcardSearch: false,
folderSelect: [
{ label: this.$t("search.onlyFolders"), value: "type:folder" },
{ label: this.$t("search.onlyFiles"), value: "type:file" },
@@ -150,13 +184,12 @@ export default {
{ label: this.$t("general.documents"), value: "type:doc" },
{ label: this.$t("general.archives"), value: "type:archive" },
],
toggleOptionButton: [{ label: this.$t("search.showOptions") }],
value: "",
ongoing: 0,
results: [],
reload: false,
scrollable: null,
hiddenOptions: true,
advancedOptionsExpanded: false,
selectedSource: "",
};
},
@@ -176,6 +209,9 @@ export default {
modifiedNewerThan() {
this.submit();
},
useWildcardSearch() {
this.submit();
},
searchTypes() {
this.submit();
},
@@ -185,6 +221,11 @@ export default {
this.results = [];
}
},
active(isNowActive) {
if (isNowActive) {
this.resetSearchOnOpen();
}
},
},
mounted() {
this.selectedSource = state.sources.current;
@@ -242,12 +283,6 @@ export default {
disableSearchOptions() {
return state.user.disableSearchOptions;
},
showOptions() {
return !this.hiddenOptions || !this.isMobile;
},
isMobile() {
return state.isMobile;
},
foldersOnly() {
return this.isTypeSelectDisabled;
},
@@ -380,34 +415,9 @@ export default {
const fullPath = context + '/' + path;
return url.buildItemUrl(source, fullPath, true);
},
enableOptions() {
this.hiddenOptions = false;
this.toggleOptionButton = [{ label: "Hide Options" }];
},
disableOptions() {
this.hiddenOptions = true;
this.toggleOptionButton = [{ label: "Show Options" }];
},
humanSize(size) {
return getHumanReadableFilesize(size);
},
/** YYYY-MM-DD from a date input → Unix seconds at 00:00:00 UTC for that calendar day. */
dateToUnixStartOfDayUTC(isoDate) {
if (isoDate === "" || typeof isoDate !== "string") {
return null;
}
const parts = isoDate.split("-");
if (parts.length !== 3) {
return null;
}
const y = Number(parts[0]);
const m = Number(parts[1]);
const d = Number(parts[2]);
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) {
return null;
}
return Math.floor(Date.UTC(y, m - 1, d) / 1000);
},
basePath(str, isDir) {
let result = url.removeLastDir(str);
if (!isDir) {
@@ -420,6 +430,15 @@ export default {
let part = parts.pop();
return part;
},
/** Clear query state whenever the overlay opens (open paths do not always call close()). */
resetSearchOnOpen() {
this.value = "";
this.results = [];
this.ongoing = 0;
this.noneMessage = this.$t("search.typeToSearch", {
minSearchLength: globalVars.minSearchLength,
});
},
close(event) {
this.value = "";
event.stopPropagation();
@@ -443,7 +462,7 @@ export default {
},
resetSearchFilters() {
this.searchTypes = "";
this.hiddenOptions = true;
this.advancedOptionsExpanded = false;
this.showPreviewImages = false;
},
removeFromTypes(string) {
@@ -478,14 +497,17 @@ export default {
searchTypesFull = searchTypesFull + "type:smallerThan=" + this.smallerThan + " ";
}
const dateParams = {};
const olderUnix = this.dateToUnixStartOfDayUTC(this.modifiedOlderThan);
const olderUnix = utcStartOfDaySecondsFromDateInput(this.modifiedOlderThan);
if (olderUnix !== null) {
dateParams.olderThan = olderUnix;
}
const newerUnix = this.dateToUnixStartOfDayUTC(this.modifiedNewerThan);
const newerUnix = utcStartOfDaySecondsFromDateInput(this.modifiedNewerThan);
if (newerUnix !== null) {
dateParams.newerThan = newerUnix;
}
if (this.useWildcardSearch) {
dateParams.useWildcard = true;
}
this.ongoing++;
// Determine which sources to search
@@ -566,6 +588,73 @@ export default {
}
});
},
openInAdvancedSearch() {
let sourcesToSearch = [];
if (this.selectedSource === "__all__" || this.selectedSource === "") {
sourcesToSearch = Object.keys(this.sourceInfo || {});
} else {
sourcesToSearch = [this.selectedSource || state.sources.current];
}
const picked = [...sourcesToSearch].sort();
/** @type {Record<string, string | string[]>} */
const query = {};
query.term = this.value.trim();
const trimmedTypes = String(this.searchTypes || "").trim();
if (trimmedTypes !== "") {
query.types = trimmedTypes;
}
if (this.largerThan !== "") {
query.largerThan = String(this.largerThan);
}
if (this.smallerThan !== "") {
query.smallerThan = String(this.smallerThan);
}
if (this.modifiedOlderThan !== "") {
query.dateOlder = String(this.modifiedOlderThan);
}
if (this.modifiedNewerThan !== "") {
query.dateNewer = String(this.modifiedNewerThan);
}
if (this.useWildcardSearch) {
query.wildcard = "1";
}
if (this.isTypeSelectDisabled) {
query.typeLock = "1";
}
/** @type {string[]} */
const scopeClauses = [];
for (const sourceName of picked) {
let scopedFolderPath = "/";
if (picked.length === 1) {
const raw = String(this.getContext || "").trim();
if (raw !== "" && raw !== "/") {
const withSlash = raw.startsWith("/") ? raw : `/${raw}`;
scopedFolderPath = url.removeTrailingSlash(withSlash);
}
}
if (scopedFolderPath === "") {
scopedFolderPath = "/";
}
if (!scopedFolderPath.startsWith("/")) {
scopedFolderPath = `/${scopedFolderPath}`;
}
scopeClauses.push(`${sourceName}:${scopedFolderPath}`);
}
if (scopeClauses.length === 1) {
query.scope = scopeClauses[0];
} else {
query.scope = scopeClauses;
}
mutations.setSearch(false);
router
.push({ path: "/tools/advancedSearch", query })
.catch(() => {});
},
addSelected(event, s) {
event.preventDefault();
const pathParts = url.removeTrailingSlash(s.path).split("/");
@@ -632,6 +721,17 @@ export default {
color: white;
}
.open-advanced-search-row {
display: flex;
justify-content: flex-end;
padding: 0.35em 0.75em 0;
}
.open-advanced-search-button {
font-size: 0.875rem;
margin: auto
}
#results>#result-list {
max-height: 80vh;
width: 35em;
@@ -789,12 +889,6 @@ export default {
background-color: var(--surfacePrimary);
}
/* Toggle switch styling in search options */
.search-option-item {
margin: 1em;
}
.text-container {
margin-left: 0.25em;
white-space: nowrap;
@@ -972,26 +1066,13 @@ body.rtl #search .boxes h3 {
min-width: fit-content;
}
/* Smooth expand/collapse animation for search options */
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease;
overflow: hidden;
.search-options-settings-item {
padding: 0.5em;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
.expand-enter-to,
.expand-leave-from {
max-height: 500px;
opacity: 1;
}
.search-options {
.search-options-inner {
box-sizing: border-box;
width: 100%;
overflow: hidden;
}
+9 -2
View File
@@ -64,8 +64,15 @@ export default {
return getters.shareHash();
},
showListingHeader() {
// Show listing header when in listing view with items
return getters.currentView() === 'listingView' && state.req?.items?.length > 0;
const path = state.route.path || "";
if (path === "/tools/advancedSearch") {
return false;
}
const itemsLen = state.req?.items?.length ?? 0;
if (itemsLen <= 0) {
return false;
}
return getters.currentView() === "listingView";
},
hasDuration() {
// Check if any file has duration metadata
+22 -10
View File
@@ -14,7 +14,7 @@
</div>
<!-- Right side: Slider to control the listing size or the font size in editor -->
<div class="status-controls">
<div v-if="currentView === 'listingView'" class="gallery-size-control">
<div v-if="showGallerySizeSlider" class="gallery-size-control">
<span class="size-label">{{ $t("general.size") }}</span>
<input
v-model="gallerySize"
@@ -32,7 +32,7 @@
<input
v-model="editorFontSize"
type="range"
min="10"
min="8"
max="24"
step="1"
/>
@@ -59,13 +59,13 @@ export default {
return getters.currentView();
},
isEditorOrMarkdownView() {
return this.currentView === 'editor' || this.currentView === 'markdownViewer';
return getters.isEditorOrMarkdownView();
},
showStatusBar() {
if (getters.isShare() && state.shareInfo.shareType === "upload") {
return false;
}
return this.currentView === "listingView" || this.isEditorOrMarkdownView;
return getters.showStatusBar();
},
showGallerySizeSlider() {
return getters.showGallerySizeSlider();
},
isDarkMode() {
return getters.isDarkMode();
@@ -126,7 +126,18 @@ export default {
return `${this.itemsSelectedLabel} (${this.displayTotalSize})`;
},
directoryInfoText() {
return `${this.numDirs} ${this.foldersLabel} | ${this.numFiles} ${this.filesLabel} (${this.displayTotalSize})`;
const dirs = this.numDirs;
const files = this.numFiles;
const sizeText = `(${this.displayTotalSize})`;
if (dirs === 0 && files === 0) {
return this.$t('files.lonely');
}
let parts = [];
if (dirs > 0) parts.push(`${dirs} ${this.foldersLabel}`);
if (files > 0) parts.push(`${files} ${this.filesLabel}`);
return parts.join(' | ') + ' ' + sizeText;
},
moveWithSidebar() {
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
@@ -206,7 +217,8 @@ export default {
const delta = event.deltaY > 0 ? 1 : -1; // Scroll down increases, up decreases
if (this.currentView === 'listingView') {
const advSearch = (state.route?.path || "").startsWith("/tools/advancedSearch");
if (this.currentView === "listingView" || advSearch) {
event.preventDefault();
let newSize = Math.min(9, Math.max(1, this.gallerySize - delta));
if (newSize !== this.gallerySize) {
@@ -216,7 +228,7 @@ export default {
}
} else if (this.currentView === 'editor') {
event.preventDefault();
let newSize = Math.min(24, Math.max(10, this.editorFontSize - delta));
let newSize = Math.min(24, Math.max(8, this.editorFontSize - delta));
if (newSize !== this.editorFontSize) {
this.editorFontSize = newSize;
}
+2 -1
View File
@@ -116,7 +116,8 @@ export default {
z-index: 9999;
pointer-events: none;
max-width: 20em;
white-space: normal;
/* Preserve newlines from i18n strings (e.g. search help) while still wrapping long lines */
white-space: pre-line;
overflow-wrap: break-word;
}
@@ -0,0 +1,334 @@
<template>
<div class="audio-side-panel" :class="{ 'dark-mode': darkMode }">
<div class="panel-tabs">
<div class="tab-container">
<input type="radio" id="tab-queue" v-model="activeTab" value="queue" hidden />
<label for="tab-queue" class="tab-btn" :class="{ active: activeTab === 'queue' }">
<i class="material-symbols">queue_music</i>
<span>{{ $t('player.queue') }}</span>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span v-if="queueCount > 0">({{ queueCount }})</span>
</label>
<input type="radio" id="tab-lyrics" v-model="activeTab" value="lyrics" hidden />
<label for="tab-lyrics" class="tab-btn" :class="{ active: activeTab === 'lyrics' }">
<i class="material-symbols">lyrics</i>
<span>{{ $t('player.lyrics') }}</span>
</label>
<div class="tab-indicator"></div>
</div>
</div>
<div class="panel-content">
<div v-if="activeTab === 'queue'" class="tab-queue">
<PlaybackQueue embedded />
</div>
<div v-else class="tab-lyrics">
<!-- Lock button -->
<button
v-if="lyrics.length && syncedLyrics"
class="lyrics-lock-btn"
@click="lyricsScrollLocked = !lyricsScrollLocked"
:title="lyricsScrollLocked ? $t('player.unlockLyrics') : $t('player.lockLyrics')"
>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<i :class="lyricsScrollLocked ? 'material-symbols-outlined' : 'material-symbols'">{{ lyricsScrollLocked ? 'lock_open' : 'lock' }}</i>
</button>
<!-- Scrollable area -->
<div class="lyrics-scrollable" ref="lyricsScrollable">
<div v-if="lyrics.length" class="lyrics-list">
<p
v-for="(line, i) in lyrics"
:key="i"
:class="{ active: syncedLyrics && lyrics[i].timestamp === lyrics[activeLyricIndex]?.timestamp, 'no-seek': !syncedLyrics }"
class="lyric-line"
@click.stop="syncedLyrics && $emit('seek', line.timestamp)"
:role="syncedLyrics ? 'button' : undefined"
:tabindex="syncedLyrics ? 0 : undefined"
:aria-label="syncedLyrics ? 'Seek to ' + line.text : undefined"
>
{{ line.text }}
</p>
</div>
<div v-else class="no-lyrics">
<i class="material-symbols">lyrics</i>
<p>{{ $t('player.noLyrics') }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import PlaybackQueue from "@/components/prompts/PlaybackQueue.vue";
import { getters, state } from "@/store";
const LAST_TAB_KEY = 'plyrSidePanelActiveTab';
export default {
name: "AudioPanel",
components: { PlaybackQueue },
props: {
lyrics: { type: Array, default: () => [] },
activeLyricIndex: { type: Number, default: -1 },
},
emits: ["seek"],
data() {
return {
activeTab: sessionStorage.getItem(LAST_TAB_KEY) === 'lyrics' ? 'lyrics' : 'queue',
lyricsScrollLocked: false,
};
},
computed: {
darkMode() { return getters.isDarkMode(); },
queueCount() {
return state.playbackQueue?.queue?.length || 0;
},
syncedLyrics() {
return this.lyrics.length > 0 && !this.lyrics.every(line => line.timestamp === 0);
},
},
watch: {
activeTab(val) {
// Persist to sessionStorage
sessionStorage.setItem(LAST_TAB_KEY, val);
// Scroll to active line when switching to lyrics
if (val === 'lyrics') {
this.$nextTick(() => this.scrollToActiveLine());
}
},
activeLyricIndex() {
if (this.activeTab === "lyrics") {
this.$nextTick(() => this.scrollToActiveLine());
}
},
lyrics: {
handler() {
if (this.activeTab === 'lyrics' && this.lyrics.length) {
this.$nextTick(() => this.scrollToActiveLine());
}
},
immediate: true,
},
lyricsScrollLocked(val) {
if (!val && this.activeTab === 'lyrics' && this.lyrics.length) {
this.$nextTick(() => this.scrollToActiveLine());
}
},
},
mounted() {
document.addEventListener('keydown', this.onKeyDown);
},
beforeUnmount() {
document.removeEventListener('keydown', this.onKeyDown);
},
methods: {
scrollToActiveLine() {
if (this.lyricsScrollLocked) return;
const el = this.$refs.lyricsScrollable;
if (!el) return;
const active = el.querySelector(".lyric-line.active");
if (active) {
active.scrollIntoView({ behavior: "smooth", block: "center" });
}
},
onKeyDown(event) {
// 'E' shortcut to switch between tabs more easily
if (event.key.toLowerCase() !== 'e' || event.repeat) return;
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return;
event.preventDefault();
this.activeTab = this.activeTab === 'queue' ? 'lyrics' : 'queue';
},
},
};
</script>
<style scoped>
.audio-side-panel {
display: flex;
flex-direction: column;
max-height: 65vh;
background: rgb(216 216 216);
border-radius: 1em;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.audio-side-panel.dark-mode {
background: rgb(37 49 55 / 33%);
}
.panel-tabs {
padding: 0.5em;
border-bottom: 1px solid var(--divider);
}
/* Container for the sliding indicator */
.tab-container {
position: relative;
display: flex;
gap: 0.5em;
}
.tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.3em;
padding: 0.4em;
border: none;
background: transparent;
color: var(--textSecondary);
border-radius: 0.8em;
cursor: pointer;
font-size: 0.9rem;
transition: 0.2s ease;
position: relative;
z-index: 1;
}
.tab-btn.active {
color: white;
}
.tab-indicator {
position: absolute;
top: 0; bottom: 0;
left: 0;
width: 50%;
background: var(--primaryColor);
border-radius: 0.8em;
z-index: 0;
transition: left 0.35s cubic-bezier(0.25, 0.8, 0.25, 1),
width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
}
/* Shift the indicator to the right when lyrics tab is active */
.tab-container:has(#tab-lyrics:checked) .tab-indicator {
left: 50%;
}
.panel-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.tab-queue,
.tab-lyrics {
height: 100%;
}
.tab-queue {
display: flex;
flex-direction: column;
}
.tab-lyrics {
position: relative;
display: flex;
flex-direction: column;
}
/* Scrollable wrapper */
.lyrics-scrollable {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.tab-queue :deep(.card-content) {
flex: 1;
overflow-y: auto;
padding: 0.5em;
margin: 0;
}
.tab-queue :deep(.card-actions) {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
padding: 0.25em;
}
.tab-queue :deep(.card-actions .button--flat) {
background: transparent;
}
.lyrics-list {
padding: 1em;
text-align: center;
color: var(--textPrimary);
}
.lyric-line {
padding: 0.4em 0;
opacity: 0.6;
cursor: pointer;
transition: opacity 0.2s;
font-size: 1.15rem;
}
.lyric-line:hover {
opacity: 1;
}
.lyric-line.active {
opacity: 1;
font-weight: bold;
color: var(--primaryColor);
font-size: 1.35rem;
}
.no-lyrics {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--textSecondary);
gap: 0.5em;
}
.no-lyrics i {
font-size: 3rem;
opacity: 0.5;
}
.lyric-line.no-seek {
cursor: default;
}
.audio-side-panel .tab-lyrics .lyrics-lock-btn {
position: absolute;
top: 0.5em;
right: 0.5em;
z-index: 10;
background: var(--background);
border: 1px solid var(--divider);
border-radius: 50%;
width: 2em;
height: 2em;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--textSecondary);
transition: 0.2s;
}
.audio-side-panel .tab-lyrics .lyrics-lock-btn:hover {
background: var(--primaryColor);
color: white;
border-color: var(--primaryColor);
}
.tab-btn:hover:not(.active) {
color: var(--primaryColor);
transform: scale(1.02);
}
</style>
@@ -257,7 +257,6 @@ export default {
}
} catch (error) {
notify.showError(this.$t("files.cannotAccesDir"));
console.log("Cannot access to target directory", e);
return;
}
@@ -297,7 +296,6 @@ export default {
} catch (error) {
mutations.closeTopPrompt();
notify.showError(this.$t("prompts.moveFailed"));
console.log("Move failed", e);
}
};
+39 -3
View File
@@ -1,7 +1,7 @@
<template>
<div class="card-content" aria-label="file-list-prompt">
<!-- Source Selection Dropdown (hidden when browsing a fixed source, e.g. user scope row) -->
<div v-if="showSourceSelector" class="source-selector" style="margin-bottom: 1rem;">
<div v-if="!hidePathChrome && showSourceSelector" class="source-selector" style="margin-bottom: 1rem;">
<label for="destinationSource" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
{{ $t("prompts.destinationSource") }}
</label>
@@ -13,7 +13,7 @@
</div>
<!-- Current Path Display -->
<div v-if="!fileList" aria-label="filelist-path" class="searchContext button clickable">
<div v-if="!hidePathChrome && !fileList" aria-label="filelist-path" class="searchContext button clickable">
{{ $t('general.path', { suffix: ':' }) }} {{ sourcePath.path }}
</div>
@@ -101,6 +101,11 @@ export default {
type: Boolean,
default: false,
},
/** Hide the source dropdown and path caption (e.g. when PathPickerButton provides both). */
hidePathChrome: {
type: Boolean,
default: false,
},
requireFileSelection: {
type: Boolean,
default: false, // If true, only files (not folders) can be selected
@@ -479,8 +484,39 @@ export default {
onSourceChange() {
this.resetToSource(this.currentSource);
},
/**
* Jump listing to a source/path (e.g. after PathPickerButton confirms).
* Normalizes to a directory path with trailing slash (except root).
*/
jumpTo(source, path) {
if (!source) {
return;
}
let p = path == null || path === "" ? "/" : String(path);
if (!p.startsWith("/")) {
p = `/${p}`;
}
if (p !== "/" && !p.endsWith("/")) {
p = `${p}/`;
}
this.currentSource = source;
this.source = source;
this.path = p;
this.selected = null;
this.selectedSource = null;
this.selectedType = null;
if (this.browseShare || getters.isShare()) {
const hashToUse = this.browseShare || state.shareInfo?.hash;
this.withLoading(() =>
resourcesApi.fetchFilesPublic(p, hashToUse).then(this.fillOptions)
);
} else {
this.withLoading(() =>
resourcesApi.fetchFiles(source, p).then(this.fillOptions)
);
}
},
fillFromList() {
// Use the provided fileList, filtering out directories to show only files
const allItems = this.fileList || [];
this.items = allItems.filter(item => !item.isDirectory && item.type !== 'directory');
},
@@ -89,6 +89,9 @@ export default {
ascOrdered() {
return getters.sorting().asc;
},
galleryView() {
return getters.viewMode() === "gallery";
},
nameIcon() {
if (this.nameSorted && !this.ascOrdered) {
return "arrow_upward";
@@ -0,0 +1,175 @@
<template>
<div
role="button"
tabindex="0"
:aria-label="resolvedAriaLabel"
class="searchContext clickable button unified-path-picker"
@click="openPicker"
@keydown.enter.prevent="openPicker"
@keydown.space.prevent="openPicker"
>
{{ buttonLabel }}
</div>
</template>
<script>
import { mutations, state } from "@/store";
import { eventBus } from "@/store/eventBus";
function normalizeContextId() {
return `path-picker-btn-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
export default {
name: "PathPickerButton",
props: {
path: {
type: String,
default: "/",
},
source: {
type: String,
default: "",
},
ariaLabel: {
type: String,
default: undefined,
},
/** i18n or plain string when nothing chosen yet. */
placeholder: {
type: String,
default: undefined,
},
showFiles: {
type: Boolean,
default: false,
},
showFolders: {
type: Boolean,
default: true,
},
hideDestinationSource: {
type: Boolean,
default: false,
},
requireFileSelection: {
type: Boolean,
default: false,
},
allowedFileTypes: {
type: Array,
default: null,
},
listTitle: {
type: String,
default: null,
},
},
emits: ["update:path", "update:source", "navigate"],
data() {
return {
pendingContextId: null,
};
},
computed: {
resolvedAriaLabel() {
if (this.ariaLabel) {
return this.ariaLabel;
}
return this.buttonLabel;
},
buttonLabel() {
const hasSource = typeof this.source === "string" && this.source.length > 0;
const p =
typeof this.path === "string" && this.path.length > 0 ? this.path : "/";
if (!hasSource) {
return (
this.placeholder !== undefined && this.placeholder !== null && this.placeholder !== ""
? this.placeholder
: this.$t("tools.fileWatcher.chooseFile")
);
}
return `${p} (${this.source})`;
},
},
mounted() {
eventBus.on("pathSelected", this.onPathSelected);
eventBus.on("pathPickerCancelled", this.onPathPickerCancelled);
},
beforeUnmount() {
eventBus.off("pathSelected", this.onPathSelected);
eventBus.off("pathPickerCancelled", this.onPathPickerCancelled);
},
methods: {
openPicker() {
const currentPath =
typeof this.path === "string" && this.path.length > 0 ? this.path : "/";
const currentSource =
(typeof this.source === "string" && this.source.length > 0
? this.source
: state.sources.current) || Object.keys(state.sources.info || {})[0] || "";
this.pendingContextId = normalizeContextId();
mutations.showPrompt({
name: "pathPicker",
props: {
currentPath,
currentSource,
showFiles: this.showFiles,
showFolders: this.showFolders,
hideDestinationSource: this.hideDestinationSource,
requireFileSelection: this.requireFileSelection,
allowedFileTypes: this.allowedFileTypes,
listTitle: this.listTitle,
selectionContextId: this.pendingContextId,
},
});
},
onPathSelected(data) {
if (!this.pendingContextId || !data) {
return;
}
if (data.selectionContextId !== this.pendingContextId) {
return;
}
this.pendingContextId = null;
if (typeof data.path === "string") {
this.$emit("update:path", data.path);
}
if (typeof data.source === "string") {
this.$emit("update:source", data.source);
}
this.$emit("navigate");
},
onPathPickerCancelled(data) {
if (!this.pendingContextId || !data) {
return;
}
if (data.selectionContextId !== this.pendingContextId) {
return;
}
this.pendingContextId = null;
},
},
};
</script>
<style scoped>
.unified-path-picker {
display: flex;
align-items: center;
justify-content: flex-start;
max-width: 100%;
min-width: 0;
word-break: break-word;
box-sizing: border-box;
}
</style>
+2 -2
View File
@@ -131,7 +131,7 @@ export default {
const sectionId = this.$refs.sectionId;
const scrollRatio = scrollTop / (content.scrollHeight - content.clientHeight);
const thumbHeight = thumb.clientHeight;
const maxThumbTop = scrollbar.clientHeight - thumbHeight - (this.isNotListing ? offsetFromBottomFull : offsetFromBottomListing);
const maxThumbTop = scrollbar.clientHeight - thumbHeight - (getters.showStatusBar() ? offsetFromBottomListing : offsetFromBottomFull);
const thumbPosition = scrollRatio * maxThumbTop;
// Use transform3d for better performance
@@ -179,7 +179,7 @@ export default {
const deltaY = clientY - this.startY;
const scrollableHeight = content.scrollHeight - content.clientHeight;
const offsetFromBottom = this.isNotListing ? offsetFromBottomFull : offsetFromBottomListing;
const offsetFromBottom = getters.showStatusBar() ? offsetFromBottomListing : offsetFromBottomFull;
const scrollbarHeight =
scrollbar.clientHeight - thumb.clientHeight - offsetFromBottom;
const scrollRatio = scrollableHeight / scrollbarHeight;
+14 -8
View File
@@ -17,10 +17,15 @@
<file-list @update:selected="updateTempPath" :browseSource="sourceName" :showFiles="true"></file-list>
</div>
<div v-else>
<p>{{ $t("general.source", { suffix: ":" }) }} {{ currentSource }}</p>
<div aria-label="access-path" class="searchContext clickable button" @click="startPathEdit">
{{ $t("general.path", { suffix: ":" }) }} {{ currentPath }}
</div>
<PathPickerButton
v-model:path="currentPath"
v-model:source="currentSource"
aria-label="access-path"
:show-files="true"
:show-folders="true"
:placeholder="$t('sidebar.chooseSource')"
@navigate="onPathPickerNavigate"
/>
<!-- Default behavior banner -->
<div class="card item">
<div class="card-content banner-content">
@@ -104,11 +109,12 @@ import { mutations } from "@/store";
import FileList from "../files/FileList.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import PathPickerButton from "@/components/files/PathPickerButton.vue";
import { eventBus } from "@/store/eventBus";
export default {
name: "access",
components: { FileList, ToggleSwitch, LoadingSpinner },
components: { FileList, ToggleSwitch, LoadingSpinner, PathPickerButton },
props: {
sourceName: { type: String, required: true },
path: { type: String, required: true, default: "/" }
@@ -173,9 +179,9 @@ export default {
}
},
methods: {
startPathEdit() {
this.tempPath = this.currentPath;
this.isEditingPath = true;
async onPathPickerNavigate() {
await this.fetchRule();
eventBus.emit("accessRulesChanged");
},
/**
* @param {{path: string, source: string}} pathOrData
@@ -123,7 +123,6 @@ export default {
</script>
<style scoped>
.api-content {
max-height: 70vh;
overflow-y: auto;
}
+29 -2
View File
@@ -37,6 +37,10 @@
<kbd>Click</kbd>
<span>{{ $t("help.click") }}</span>
</div>
<div class="shortcut-item">
<kbd>Double click</kbd>
<span>{{ $t("help.dbClick") }}</span>
</div>
<div class="shortcut-item">
<kbd>Click + Drag</kbd>
<span>{{ $t("help.marqueeSelect") }}</span>
@@ -80,13 +84,29 @@
<span>{{ $t("help.ctrl.comma") }}</span>
</div>
<div class="shortcut-item">
<kbd>/</kbd>
<kbd>space or /</kbd>
<span>{{ $t("help.slash") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + E</kbd>
<span>{{ $t("help.ctrl.e") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F1</kbd>
<span>{{ $t("help.ctrl.f1") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F2</kbd>
<span>{{ $t("help.ctrl.f2") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F3</kbd>
<span>{{ $t("help.ctrl.f3") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + Scroll</kbd>
<span>{{ $t("help.ctrl.scroll") }}</span>
</div>
</div>
<!-- Media Section -->
@@ -108,6 +128,14 @@
<kbd>↑ / ↓</kbd>
<span>{{ $t("help.media.volume") }}</span>
</div>
<div class="shortcut-item">
<kbd>Q</kbd>
<span>{{ $t("help.media.playbackQueue") }}</span>
</div>
<div class="shortcut-item">
<kbd>E</kbd>
<span>{{ $t("help.media.switchTabs") }}</span>
</div>
<div class="shortcut-item">
<kbd>L</kbd>
<span>{{ $t("help.media.loop") }}</span>
@@ -155,7 +183,6 @@ export default {
<style scoped>
.help-content {
max-height: 70vh;
overflow-y: auto;
}
@@ -38,7 +38,7 @@
</div>
<!-- Icon Grid -->
<div class="icon-grid">
<div class="icon-grid" :key="searchQueryKey">
<!-- Custom Icon Preview (if searching and doesn't exactly match existing) -->
<div
v-if="showCustomPreview"
@@ -137,6 +137,9 @@ export default {
// Limit results for performance (more now that cards are smaller)
return this.filteredIcons.slice(0, 150);
},
searchQueryKey() {
return this.searchQuery.trim();
},
},
mounted() {
// Focus the search input when the picker opens
@@ -159,7 +162,6 @@ export default {
<style scoped>
.card-content {
max-height: 60vh;
overflow-y: auto;
}
+32 -3
View File
@@ -7,7 +7,21 @@
<p class="loading-text">{{ $t("prompts.operationInProgress") }}</p>
</div>
<div v-show="!isLoading">
<file-list ref="fileList" @update:selected="updateDestination">
<PathPickerButton
v-if="!isShareContext"
v-model:path="destPath"
v-model:source="destSource"
class="move-copy-path-picker"
:show-files="false"
:show-folders="true"
:placeholder="$t('sidebar.chooseSource')"
@navigate="syncFileListFromPicker"
/>
<file-list
ref="fileList"
:hide-path-chrome="!isShareContext"
@update:selected="updateDestination"
>
</file-list>
</div>
</div>
@@ -47,10 +61,11 @@ import { url } from "@/utils";
import { notify } from "@/notify";
import { goToItem } from "@/utils/url";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import PathPickerButton from "@/components/files/PathPickerButton.vue";
export default {
name: "move-copy",
components: { FileList, LoadingSpinner },
components: { FileList, LoadingSpinner, PathPickerButton },
props: {
operation: {
type: String,
@@ -112,7 +127,10 @@ export default {
},
isDirNameValid() {
return this.validateDirName(this.newDirName);
}
},
isShareContext() {
return getters.isShare();
},
},
mounted() {
// If props.items is provided, use it. Otherwise use state
@@ -152,6 +170,13 @@ export default {
}
},
methods: {
syncFileListFromPicker() {
this.$nextTick(() => {
if (this.$refs.fileList && this.destSource && this.destPath) {
this.$refs.fileList.jumpTo(this.destSource, this.destPath);
}
});
},
createNewDir() {
this.showNewDirInput = true;
this.newDirName = "";
@@ -418,4 +443,8 @@ export default {
.split-buttons {
justify-content: space-between;
}
.move-copy-path-picker {
margin-bottom: 1rem;
}
</style>
@@ -513,7 +513,6 @@ export default {
}
.card-content {
max-height: 70vh;
overflow-y: auto;
}
</style>
@@ -10,7 +10,7 @@
<!-- Queue list -->
<div v-if="formattedQueue.length > 0" class="queue-container">
<div class="file-list" ref="QueueList">
<div class="queue-list" ref="QueueList">
<div
v-for="(item, index) in formattedQueue"
:key="`${item.path}-${index}`"
@@ -54,6 +54,16 @@ import { state, mutations } from "@/store";
import { url } from "@/utils";
export default {
name: "PlaybackQueue",
props: {
embedded: {
type: Boolean,
default: false
},
promptId: {
type: Number,
default: null,
},
},
computed: {
playbackQueue() {
return state.playbackQueue?.queue || [];
@@ -110,11 +120,13 @@ export default {
this.scrollToCurrentItem();
});
}
if (this.embedded && newIndex !== oldIndex) {
this.$nextTick(() => this.scrollToCurrentItem());
}
},
isPromptVisible: {
handler(newVal) {
if (newVal) {
// Prompt just became visible, scroll to current item
if (!this.embedded && newVal) {
this.$nextTick(() => {
setTimeout(() => {
this.scrollToCurrentItem();
@@ -125,19 +137,17 @@ export default {
immediate: true
},
playbackMode(newMode, oldMode) {
// Auto-scroll when mode changes
if (newMode !== oldMode) {
this.$nextTick(() => {
this.scrollToCurrentItem();
});
if (newMode !== oldMode && (this.isPromptVisible || this.embedded)) {
this.$nextTick(() => this.scrollToCurrentItem());
}
},
queueCount() {
this.updatePromptTitle();
},
},
mounted() {
// Auto-scroll to current item when prompt opens
this.$nextTick(() => {
this.scrollToCurrentItem();
});
this.$nextTick(() => this.scrollToCurrentItem());
this.updatePromptTitle();
},
methods: {
cyclePlaybackModes() {
@@ -192,23 +202,37 @@ export default {
this.$nextTick(() => {
const list = this.$refs.QueueList;
const currentItem = list.querySelector('.item.current');
if (currentItem) {
// Calculate the scroll position to center the current item
const itemTop = currentItem.offsetTop;
const itemHeight = currentItem.offsetHeight;
const listHeight = list.clientHeight;
const scrollTo = itemTop - (listHeight / 2) + (itemHeight / 2);
list.scrollTo({
top: Math.max(0, scrollTo),
behavior: 'smooth'
});
}
if (!currentItem) return;
const listRect = list.getBoundingClientRect();
const itemRect = currentItem.getBoundingClientRect();
const itemTopRelative = itemRect.top - listRect.top + list.scrollTop;
const itemHeight = currentItem.offsetHeight;
const listHeight = list.clientHeight;
const maxScroll = list.scrollHeight - listHeight;
// Target the item at 35% from the top
const viewportOffset = listHeight * 0.35;
const scrollTo = itemTopRelative - viewportOffset + (itemHeight / 2);
list.scrollTo({
top: Math.max(0, Math.min(scrollTo, maxScroll)),
behavior: 'smooth'
});
});
},
getFileIcon(item) {
if (item.type?.startsWith('audio/')) return 'audiotrack';
if (item.type?.startsWith('video/')) return 'movie';
}
},
updatePromptTitle() {
if (this.embedded || this.promptId == null) return;
const base = this.$t('player.QueuePlayback');
const title = this.queueCount > 0
? `${base} (${this.queueCount})`
: base;
mutations.updatePromptTitle(this.promptId, title);
},
}
};
</script>
@@ -274,7 +298,7 @@ export default {
flex-direction: column;
}
.file-list {
.queue-list {
overflow-y: auto;
min-height: 0;
align-items: center;
@@ -325,7 +349,11 @@ export default {
}
.empty {
padding: 2rem;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: var(--textSecondary);
}
@@ -333,7 +361,6 @@ export default {
.empty i.material-symbols {
font-size: 3rem;
opacity: 0.5;
margin-bottom: 1rem;
user-select: none;
}
</style>
+1 -1
View File
@@ -416,7 +416,7 @@ export default {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
this.closeTopPrompt(top.id);
return;
}
+107 -45
View File
@@ -34,50 +34,51 @@
<p> {{ $t('share.notice') }} </p>
<div v-if="listing">
<table>
<tbody>
<tr>
<th>#</th> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<th>{{ $t("time.unit") }}</th>
<th></th>
<th></th>
<th></th>
</tr>
<tr v-for="link in links" :key="link.hash">
<td>{{ link.hash }}</td>
<td>
<template v-if="link.expire !== 0">{{ humanTime(link.expire) }}</template>
<template v-else>{{ $t("general.permanent") }}</template>
</td>
<td class="small">
<button class="action" @click="editLink(link)" :aria-label="$t('general.edit')"
:title="$t('general.edit')">
<i class="material-symbols">edit</i>
</button>
</td>
<td class="small">
<button class="action" @click.stop="copyToClipboard(link.shareURL)"
:aria-label="$t('buttons.copyToClipboard')" :title="$t('buttons.copyToClipboard')">
<i class="material-symbols">content_paste</i>
</button>
</td>
<td class="small">
<button :disabled="link.shareType == 'upload'" class="action" @click.stop="copyToClipboard(link.downloadURL)"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')">
<i class="material-symbols">content_paste_go</i>
</button>
</td>
<td class="small">
<button class="action" @click="deleteLink($event, link)" :aria-label="$t('general.delete')"
:title="$t('general.delete')">
<i class="material-symbols">delete</i>
</button>
</td>
</tr>
</tbody>
</table>
<settings-table
:columns="sharePromptTableColumns"
:items="links"
item-key="hash"
:aria-label="$t('settings.shareManagement')"
:loading="linksLoading"
>
<template #cell-expire="{ row }">
<template v-if="row.expire !== 0">{{ humanTime(row.expire) }}</template>
<template v-else>{{ $t("general.permanent") }}</template>
</template>
<template #cell-editShare="{ row }">
<button class="action" @click="editLink(row)" :aria-label="$t('general.edit')"
:title="$t('general.edit')"
>
<i class="material-symbols">edit</i>
</button>
</template>
<template #cell-copyShare="{ row }">
<button class="action" @click.stop="copyToClipboard(row.shareURL)"
:aria-label="$t('buttons.copyToClipboard')" :title="$t('buttons.copyToClipboard')"
>
<i class="material-symbols">content_paste</i>
</button>
</template>
<template #cell-downloadShare="{ row }">
<button
:disabled="row.shareType === 'upload'"
class="action"
v-if="row.downloadURL"
@click.stop="copyToClipboard(row.downloadURL)"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')"
>
<i class="material-symbols">content_paste_go</i>
</button>
</template>
<template #cell-deleteShare="{ row }">
<button class="action" @click="deleteLink($event, row)" :aria-label="$t('general.delete')"
:title="$t('general.delete')"
>
<i class="material-symbols">delete</i>
</button>
</template>
</settings-table>
</div>
<div v-else>
<div v-if="!showMoreExpanded">
@@ -212,6 +213,21 @@
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="showHidden"
:name="$t('profileSettings.showHiddenFiles')"
:description="$t('profileSettings.showHiddenFilesDescription')" />
<div>
<p>
{{ $t("profileSettings.hideFileExt") }}
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('profileSettings.hideFileExtDescription'))"
@mouseleave="hideTooltip">
help
</i>
</p>
<input class="input"
:class="{ 'form-invalid': !validateExtensions(hideFileExt) }"
type="text"
:placeholder="$t('profileSettings.disableFileExtensions')"
v-model.trim="hideFileExt" />
</div>
<ToggleSwitch v-if="shareType !== 'upload'" class="item" v-model="disableNavButtons"
:name="$t('share.hideNavButtons')" :description="$t('share.hideNavButtonsDescription')" />
<ToggleSwitch class="item" v-model="disableShareCard" :name="$t('share.disableShareCard')"
@@ -326,6 +342,7 @@ import { fromNow } from "@/utils/moment";
import { buildItemUrl } from "@/utils/url";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import SettingsItem from "@/components/settings/SettingsItem.vue";
import SettingsTable from "@/components/settings/Table.vue";
import FileList from "../files/FileList.vue";
import { globalVars } from "@/utils/constants";
import { eventBus } from "@/store/eventBus";
@@ -336,6 +353,7 @@ export default {
components: {
ToggleSwitch,
SettingsItem,
SettingsTable,
FileList,
//ViewMode,
},
@@ -379,6 +397,7 @@ export default {
disableFileViewer: false,
disableThumbnails: false,
showHidden: false,
hideFileExt: "",
enableAllowedUsernames: false,
allowedUsernames: "",
keepAfterExpiration: false,
@@ -410,9 +429,16 @@ export default {
pendingBannerFaviconContextId: null,
filePickerField: null, // 'banner' or 'favicon'
showMoreExpanded: false,
/** True while fetching existing shares for the path (create flow); table uses its placeholder spinner. */
linksLoading: true,
//viewMode: "normal",
};
},
created() {
if (this.isEditMode) {
this.linksLoading = false;
}
},
computed: {
createAllowed() {
return this.allowCreate;
@@ -446,6 +472,27 @@ export default {
isFiles() {
return getters.isFiles(); // Access getter directly from the store
},
sharePromptTableColumns() {
return [
{
key: "hash",
label: this.$t("general.hash"),
sortable: true,
align: "center",
},
{
key: "expire",
label: this.$t("general.expiration"),
sortable: true,
sortFn: (a, b) => (a.expire ?? 0) - (b.expire ?? 0),
align: "center",
},
{ key: "editShare", label: "", narrow: true, align: "center" },
{ key: "copyShare", label: "", narrow: true, align: "center" },
{ key: "downloadShare", label: "", narrow: true, align: "center" },
{ key: "deleteShare", label: "", narrow: true, align: "center" },
];
},
url() {
if (state.isSearchActive) {
const file = /** @type {FilebrowserFile} */ (this.selected[0]);
@@ -510,6 +557,7 @@ export default {
this.disableThumbnails = this.link.disableThumbnails || false;
this.disableFileViewer = this.link.disableFileViewer || false;
this.showHidden = this.link.showHidden || false;
this.hideFileExt = this.link.hideFileExt || "";
this.enableAllowedUsernames = Array.isArray(this.link.allowedUsernames) && this.link.allowedUsernames.length > 0;
this.allowedUsernames = this.enableAllowedUsernames ? this.link.allowedUsernames.join(", ") : "";
this.keepAfterExpiration = this.link.keepAfterExpiration || false;
@@ -536,15 +584,18 @@ export default {
},
async beforeMount() {
if (this.isEditMode) {
this.linksLoading = false;
return;
}
this.linksLoading = true;
try {
// get last element of the path
const links = await shareApi.get(this.item.path, this.item.source);
this.links = links;
} catch (err) {
console.error(err);
return;
} finally {
this.linksLoading = false;
}
this.sort();
@@ -587,6 +638,10 @@ export default {
mutations.hideTooltip();
},
async submit() {
if (this.hideFileExt !== "" && !this.validateExtensions(this.hideFileExt)) {
notify.showError("Invalid input, does not match requirement.");
return;
}
try {
if (!this.description) {
if (this.shareType === 'upload') {
@@ -617,6 +672,7 @@ export default {
disableFileViewer: this.disableFileViewer,
disableThumbnails: this.disableThumbnails,
showHidden: this.showHidden,
hideFileExt: this.hideFileExt,
allowedUsernames: this.enableAllowedUsernames ? this.allowedUsernames.split(',').map(u => u.trim()) : [],
keepAfterExpiration: this.keepAfterExpiration,
hash: '',
@@ -709,6 +765,7 @@ export default {
this.disableThumbnails = link.disableThumbnails || false;
this.disableFileViewer = link.disableFileViewer || false;
this.showHidden = link.showHidden || false;
this.hideFileExt = link.hideFileExt || "";
this.enableAllowedUsernames = Array.isArray(link.allowedUsernames) && link.allowedUsernames.length > 0;
this.allowedUsernames = this.enableAllowedUsernames ? link.allowedUsernames.join(", ") : "";
this.keepAfterExpiration = link.keepAfterExpiration || false;
@@ -936,6 +993,11 @@ export default {
this.pendingBannerFaviconContextId = null;
this.filePickerField = null;
},
validateExtensions(value) {
if (value === "" || value === "*") return true;
const regex = /^\.\w+(?: \.\w+)*$/;
return regex.test(value);
},
},
};
</script>
@@ -36,6 +36,13 @@
</div>
</div>
<div v-if="!showAddForm && context === 'user'" class="settings-items">
<ToggleSwitch class="item" :modelValue="showToolsInSidebar"
@update:modelValue="onShowToolsInSidebarChange"
:name="$t('profileSettings.showToolsInSidebar')"
:description="$t('profileSettings.showToolsInSidebarDescription')" />
</div>
<!-- Add New Link Section -->
<div v-if="!showAddForm" class="add-link-section">
<button @click="showAddForm = true" class="button button--flat button--blue add-link-button">
@@ -62,7 +69,7 @@
<select aria-label="Link Type" :value="linkTypeSelectValue" @change="onLinkTypeChange" class="input">
<option value="">{{ $t('sidebar.selectLinkType') }}</option>
<option v-if="context === 'user'" value="source">{{ $t('general.source') }}</option>
<option value="share">{{ $t('general.share') }}</option>
<option v-if="canListShares" value="share">{{ $t('general.share') }}</option>
<option v-if="context === 'user'" value="tool">{{ $t('general.tool') }}</option>
<option value="custom">{{ $t('sidebar.customLink') }}</option>
<option value="divider">{{ $t('general.divider') }}</option>
@@ -243,7 +250,7 @@
</template>
<script>
import { state, mutations } from "@/store";
import { state, getters, mutations } from "@/store";
import { notify } from "@/notify";
import { usersApi, shareApi } from "@/api";
import { tools } from "@/utils/constants";
@@ -293,6 +300,7 @@ export default {
isSelectingPath: false,
tempSelectedPath: "",
tempSelectedSource: "",
showToolsInSidebar: true,
};
},
computed: {
@@ -347,6 +355,10 @@ export default {
}
return this.newLink.category;
},
/** Share link type requires manage-share permission; avoids listing shares when forbidden */
canListShares() {
return this.context === "user" && getters.permissions().share;
},
},
async mounted() {
// Initialize with existing sidebar links based on context
@@ -359,12 +371,45 @@ export default {
this.links = this.getDefaultLinks();
}
// Load available shares for share link type
if (this.context === 'user') {
await this.loadAvailableShares();
if (typeof state.user?.showToolsInSidebar === 'boolean') {
this.showToolsInSidebar = state.user.showToolsInSidebar;
} else {
this.showToolsInSidebar = true;
}
this.syncMainToolsHubLinkRow(this.showToolsInSidebar);
}
// Share list loads lazily when adding/editing a share link (requires permission).
},
methods: {
/** Main Tools hub uses category "tool" and target "/tools"; keep in sync when the toggle changes. */
isMainToolsHubLink(link) {
return link?.category === "tool" && link?.target === "/tools";
},
syncMainToolsHubLinkRow(showHub) {
if (this.context !== "user") return;
if (!showHub) {
this.links = this.links.filter((link) => !this.isMainToolsHubLink(link));
return;
}
if (this.links.some((link) => this.isMainToolsHubLink(link))) {
return;
}
const mainEntry = this.availableTools.find((t) => t.path === "/tools");
const name = mainEntry ? this.$t(mainEntry.name) : this.$t("tools.title");
const icon = mainEntry ? mainEntry.icon : "build";
this.links.push({
name,
category: "tool",
target: "/tools",
icon,
});
},
onShowToolsInSidebarChange(value) {
this.showToolsInSidebar = value;
this.syncMainToolsHubLinkRow(value);
},
getIconClass,
getShareHash(target) {
// Extract hash from /public/share/<hash> or /public/share/<hash>/path
@@ -398,6 +443,10 @@ export default {
this.newLink.icon = iconName;
},
async loadAvailableShares() {
if (!this.canListShares) {
this.availableShares = [];
return;
}
try {
this.availableShares = await shareApi.list();
} catch (error) {
@@ -528,6 +577,9 @@ export default {
this.newLink.icon = "download";
}
}
if (value === "share") {
void this.loadAvailableShares();
}
},
handleSourceChange() {
if (this.newLink.sourceName) {
@@ -623,6 +675,9 @@ export default {
sourceName: link.sourceName || "",
sourcePath: isSource ? (link.target || "/") : "/",
};
if (link.category === "share") {
void this.loadAvailableShares();
}
},
addLink() {
if (!this.isNewLinkValid) return;
@@ -799,12 +854,15 @@ export default {
id: state.user.id,
username: state.user.username,
sidebarLinks: this.links,
showToolsInSidebar: this.showToolsInSidebar,
};
await usersApi.update(updatedUser, ['sidebarLinks']);
await usersApi.update(updatedUser, ['sidebarLinks', 'showToolsInSidebar']);
// Update the local state
state.user.sidebarLinks = [...this.links];
mutations.updateCurrentUser({
sidebarLinks: [...this.links],
showToolsInSidebar: this.showToolsInSidebar,
});
notify.showSuccessToast(this.$t("sidebar.linksUpdatedSuccess"));
}
@@ -821,12 +879,13 @@ export default {
<style scoped>
.padding-top {
.settings-items {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.links-list {
margin-bottom: 1.5em;
.padding-top {
margin-top: 0.5em;
}
.links-list h3,
@@ -900,13 +959,6 @@ export default {
color: var(--textSecondary);
}
/* Form sections */
.add-link-section {
margin-top: 1.5em;
padding-top: 1em;
border-top: 1px solid var(--borderColor);
}
.add-link-button {
width: 100%;
display: flex;
@@ -75,7 +75,6 @@ export default {
<style scoped>
.card-content {
min-width: 300px;
max-height: 70vh;
overflow-y: auto;
}
+33 -1
View File
@@ -17,6 +17,10 @@
:title="$t('general.verify')">
{{ $t("general.verify") }}
</button>
<button v-if="!succeeded && passkeyAvailable" type="button" class="button button--flat" @click="usePasskey"
:title="$t('login.passkeyLogin')">
<i class="material-symbols-outlined">passkey</i> {{ $t("login.passkeyLogin") }}
</button>
</div>
</template>
@@ -25,6 +29,7 @@ import { mutations } from "@/store";
import { notify } from "@/notify";
import { authApi } from "@/api";
import { initAuth } from "@/utils/auth";
import { globalVars } from "@/utils/constants";
import QrcodeVue from "qrcode.vue";
export default {
@@ -41,6 +46,11 @@ export default {
verifyInFlight: false,
};
},
computed: {
passkeyAvailable() {
return globalVars.passkeyAvailable;
},
},
props: {
redirect: {
type: String,
@@ -118,7 +128,29 @@ export default {
mutations.closeTopPrompt();
} catch (error) {
this.error = this.$t("otp.verificationFailed");
console.log("error", error);
console.error("error", error);
} finally {
this.verifyInFlight = false;
}
},
async usePasskey() {
if (String(this.password ?? "").trim() === "") {
this.error = this.$t("otp.passwordRequiredForGeneration");
return;
}
this.verifyInFlight = true;
try {
await authApi.beginPasskeyLogin(this.username, this.password);
if (this.redirect != "") {
await initAuth();
await this.$router.push(this.redirect);
}
this.succeeded = true;
this.error = "";
mutations.closeTopPrompt();
} catch (error) {
this.error = error.message || this.$t("login.failedLogin");
console.error("error", error);
} finally {
this.verifyInFlight = false;
}
@@ -699,7 +699,6 @@ export default {
.card.floating {
display: flex !important;
flex-direction: column;
max-height: 85vh;
}
.card-content {
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more