mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
37
Commits
main
...
v1.4.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6deb9b8ab | ||
|
|
215a3fd858 | ||
|
|
9c5df98b79 | ||
|
|
0382bec1cd | ||
|
|
532632de1c | ||
|
|
6c72d1b85f | ||
|
|
f4587d8ef3 | ||
|
|
3db63bd44c | ||
|
|
708a40b788 | ||
|
|
fa40a7472d | ||
|
|
548221c28c | ||
|
|
77d3ab9554 | ||
|
|
e2f27d1391 | ||
|
|
a8f2477879 | ||
|
|
d70ed3d806 | ||
|
|
786cabb666 | ||
|
|
d36c0bf8f9 | ||
|
|
8f78112a94 | ||
|
|
a4d20c8101 | ||
|
|
d0f5cc3593 | ||
|
|
e8c8ea2454 | ||
|
|
08fc72de8c | ||
|
|
09bfc919d6 | ||
|
|
ad13b56d7f | ||
|
|
6aae66551b | ||
|
|
ff72ca3061 | ||
|
|
595e167d47 | ||
|
|
9afc5f0017 | ||
|
|
6bb451758a | ||
|
|
54cdea3391 | ||
|
|
4b97b19a21 | ||
|
|
0fff81583d | ||
|
|
14ada6bba8 | ||
|
|
9b684ba40d | ||
|
|
977f64b248 | ||
|
|
95eb5da737 | ||
|
|
3bb4c099c1 |
No files matched your search
@@ -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,7 +1,7 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
|
||||
[](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
|
||||
[](https://app.codacy.com/gh/gtsteffaniak/filebrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://hub.docker.com/r/gtstef/filebrowser)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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,6 +1,7 @@
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
|
||||
@@ -2,7 +2,8 @@ server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ server:
|
||||
port: 8080
|
||||
baseURL: "/subpath"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
|
||||
@@ -2,7 +2,8 @@ server:
|
||||
port: 8080
|
||||
baseURL: "/files/"
|
||||
logging:
|
||||
- levels: "info|error|debug"
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: '.*'
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -446,6 +446,7 @@ var types = map[string]string{
|
||||
".ass": "text/subtitle-ass",
|
||||
".vtt": "text/subtitle-vtt",
|
||||
".ssa": "text/subtitle-ssa",
|
||||
".lrc": "text/lyrics",
|
||||
".sbv": "text/plain",
|
||||
".ssi": "text/x-server-parsed-html",
|
||||
".ssm": "application/streamingmedia",
|
||||
|
||||
@@ -25,7 +25,11 @@ func MakeUserDirs(u *users.User, createDir bool) error {
|
||||
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
|
||||
}
|
||||
for i, scope := range u.Scopes {
|
||||
source, ok := settings.Config.Server.SourceMap[scope.Name]
|
||||
info, ok := users.ResolveSourceKey(scope.Name)
|
||||
if !ok {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
}
|
||||
source, ok := settings.Config.Server.SourceMap[info.Path]
|
||||
if !ok {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 ---")
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
"sourceA": &sourceA,
|
||||
"sourceB": &sourceB,
|
||||
}
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
// ---------------------
|
||||
// Test Cases Definition
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -286,7 +286,7 @@ func buildPathMapRecursive(structType reflect.Type, structValue reflect.Value, c
|
||||
fieldType := field.Type
|
||||
|
||||
// Handle pointers
|
||||
if fieldType.Kind() == reflect.Ptr {
|
||||
if fieldType.Kind() == reflect.Pointer {
|
||||
if fieldValue.IsNil() {
|
||||
// Skip nil pointers
|
||||
continue
|
||||
@@ -479,7 +479,7 @@ func CollectCommentsFromEmbeddedYaml(yamlContent string) (CommentsMap, SecretFie
|
||||
// parseDefaultsFromEmbeddedYaml creates a default config by parsing the embedded YAML template
|
||||
func parseDefaultsFromEmbeddedYaml(embeddedYaml string) (*Settings, error) {
|
||||
// Start with baseline defaults from setDefaults to ensure all fields are initialized
|
||||
defaultConfig := setDefaults(true)
|
||||
defaultConfig := SetDefaults(true)
|
||||
defaultConfig.Server.Sources = []*Source{{Path: "."}}
|
||||
|
||||
// Parse the embedded YAML to overlay the documented defaults
|
||||
@@ -516,7 +516,7 @@ func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
|
||||
// buildNodeWithDefaults constructs a yaml.Node for any Go value, skipping fields that match defaults, redacting secrets, and filtering deprecated fields
|
||||
func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.Value, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
|
||||
// Dereference pointers
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
||||
}
|
||||
@@ -790,7 +790,7 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
|
||||
elemType := v.Type().Elem()
|
||||
// Check if element is a struct or pointer to struct
|
||||
isStructSlice := elemType.Kind() == reflect.Struct ||
|
||||
(elemType.Kind() == reflect.Ptr && elemType.Elem().Kind() == reflect.Struct)
|
||||
(elemType.Kind() == reflect.Pointer && elemType.Elem().Kind() == reflect.Struct)
|
||||
if !isStructSlice {
|
||||
seq.Style = yaml.FlowStyle
|
||||
}
|
||||
@@ -995,7 +995,7 @@ func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFul
|
||||
|
||||
// identifySecretFieldsByReflection identifies secret fields by known field names
|
||||
func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets SecretFieldsMap) {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return
|
||||
}
|
||||
@@ -1030,9 +1030,9 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
|
||||
|
||||
// Recursively check nested structs
|
||||
fieldValue := v.Field(i)
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
nestedTypeName := field.Type.Name()
|
||||
if field.Type.Kind() == reflect.Ptr {
|
||||
if field.Type.Kind() == reflect.Pointer {
|
||||
nestedTypeName = field.Type.Elem().Name()
|
||||
}
|
||||
identifySecretFieldsByReflection(fieldValue, nestedTypeName, secrets)
|
||||
@@ -1042,7 +1042,7 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
|
||||
|
||||
// identifyDeprecatedFieldsByReflection identifies deprecated fields by known field names
|
||||
func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, deprecated DeprecatedFieldsMap) {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.IsNil() {
|
||||
return
|
||||
}
|
||||
@@ -1072,9 +1072,9 @@ func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, depr
|
||||
|
||||
// Recursively check nested structs
|
||||
fieldValue := v.Field(i)
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
nestedTypeName := field.Type.Name()
|
||||
if field.Type.Kind() == reflect.Ptr {
|
||||
if field.Type.Kind() == reflect.Pointer {
|
||||
nestedTypeName = field.Type.Elem().Name()
|
||||
}
|
||||
identifyDeprecatedFieldsByReflection(fieldValue, nestedTypeName, deprecated)
|
||||
@@ -1108,7 +1108,7 @@ func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, e
|
||||
}
|
||||
if err != nil || readErr != nil {
|
||||
// Fallback to setDefaults
|
||||
defaultConfigValue := setDefaults(true)
|
||||
defaultConfigValue := SetDefaults(true)
|
||||
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
|
||||
defaultConfig = &defaultConfigValue
|
||||
}
|
||||
@@ -1201,7 +1201,7 @@ func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull
|
||||
}
|
||||
if err != nil || readErr != nil {
|
||||
// Fallback to setDefaults
|
||||
defaultConfigValue := setDefaults(true)
|
||||
defaultConfigValue := SetDefaults(true)
|
||||
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
|
||||
defaultConfig = &defaultConfigValue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// TestMigrateUserDefaults_OldToNew verifies that deprecated fields are correctly migrated to new fields
|
||||
func TestMigrateUserDefaults_OldToNew(t *testing.T) {
|
||||
// Setup: Create UserDefaults with old fields set
|
||||
Config.UserDefaults = UserDefaults{
|
||||
// Old deprecated fields
|
||||
DarkMode: boolPtr(true),
|
||||
DisableQuickToggles: true,
|
||||
HideSidebarFileActions: true,
|
||||
StickySidebar: true,
|
||||
SingleClick: true,
|
||||
HideFilesInTree: true,
|
||||
ShowToolsInSidebar: boolPtr(false),
|
||||
DeleteWithoutConfirming: true,
|
||||
DateFormat: true,
|
||||
ShowHidden: true,
|
||||
QuickDownload: true,
|
||||
ShowSelectMultiple: true,
|
||||
HideFileExt: ".tmp",
|
||||
ShowCopyPath: true,
|
||||
DeleteAfterArchive: true,
|
||||
DisablePreviewExt: ".exe",
|
||||
EditorQuickSave: true,
|
||||
DisableViewingExt: ".bin",
|
||||
DisableOnlyOfficeExt: ".md",
|
||||
PreferEditorForMarkdown: true,
|
||||
DebugOffice: true,
|
||||
DisableSearchOptions: true,
|
||||
ThemeColor: "var(--red)",
|
||||
CustomTheme: "dark",
|
||||
Locale: "de",
|
||||
LockPassword: true,
|
||||
DisableSettings: true,
|
||||
LoginMethod: "oidc",
|
||||
DisableUpdateNotifications: true,
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Api: true,
|
||||
Admin: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Realtime: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Download: boolPtr(false),
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
Image: boolPtr(true),
|
||||
Video: boolPtr(true),
|
||||
Audio: boolPtr(true),
|
||||
MotionVideoPreview: boolPtr(true),
|
||||
Office: boolPtr(true),
|
||||
PopUp: boolPtr(true),
|
||||
HighQuality: boolPtr(true),
|
||||
Folder: boolPtr(true),
|
||||
Models: boolPtr(true),
|
||||
// Deprecated fields that should be migrated
|
||||
DisableHideSidebar: true,
|
||||
AutoplayMedia: true,
|
||||
DefaultMediaPlayer: true,
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 5,
|
||||
UploadChunkSize: 20,
|
||||
},
|
||||
}
|
||||
|
||||
// Execute: Run migration
|
||||
migrateUserDefaults()
|
||||
|
||||
// Verify: Check that new fields are populated from old fields
|
||||
ud := &Config.UserDefaults
|
||||
|
||||
// Sidebar fields
|
||||
if !ud.Sidebar.DisableQuickToggles {
|
||||
t.Error("sidebar.disableQuickToggles should be true")
|
||||
}
|
||||
if !ud.Sidebar.HideFileActions {
|
||||
t.Error("sidebar.hideFileActions should be true")
|
||||
}
|
||||
if !ud.Sidebar.Sticky {
|
||||
t.Error("sidebar.sticky should be true")
|
||||
}
|
||||
if !ud.Sidebar.HideFiles {
|
||||
t.Error("sidebar.hideFiles should be true")
|
||||
}
|
||||
if ud.Sidebar.ShowTools == nil || *ud.Sidebar.ShowTools != false {
|
||||
t.Error("sidebar.showTools should be false")
|
||||
}
|
||||
if !ud.Sidebar.DisableHideOnPreview {
|
||||
t.Error("sidebar.disableHideOnPreview should be true")
|
||||
}
|
||||
|
||||
// Listing fields
|
||||
if !ud.Listing.DeleteWithoutConfirming {
|
||||
t.Error("listing.deleteWithoutConfirming should be true")
|
||||
}
|
||||
if !ud.Listing.DateFormat {
|
||||
t.Error("listing.dateFormat should be true")
|
||||
}
|
||||
if !ud.Listing.ShowHidden {
|
||||
t.Error("listing.showHidden should be true")
|
||||
}
|
||||
if !ud.Listing.QuickDownload {
|
||||
t.Error("listing.quickDownload should be true")
|
||||
}
|
||||
if !ud.Listing.ShowSelectMultiple {
|
||||
t.Error("listing.showSelectMultiple should be true")
|
||||
}
|
||||
if !ud.Listing.SingleClick {
|
||||
t.Error("listing.singleClick should be true")
|
||||
}
|
||||
if ud.Listing.HideFileExt != ".tmp" {
|
||||
t.Errorf("listing.hideFileExt should be '.tmp', got '%s'", ud.Listing.HideFileExt)
|
||||
}
|
||||
if !ud.Listing.ShowCopyPath {
|
||||
t.Error("listing.showCopyPath should be true")
|
||||
}
|
||||
if !ud.Listing.DeleteAfterArchive {
|
||||
t.Error("listing.deleteAfterArchive should be true")
|
||||
}
|
||||
|
||||
// Preview fields
|
||||
if ud.Preview.Image == nil || *ud.Preview.Image != true {
|
||||
t.Error("preview.image should be true")
|
||||
}
|
||||
if ud.Preview.Video == nil || *ud.Preview.Video != true {
|
||||
t.Error("preview.video should be true")
|
||||
}
|
||||
if ud.Preview.Audio == nil || *ud.Preview.Audio != true {
|
||||
t.Error("preview.audio should be true")
|
||||
}
|
||||
if ud.Preview.DisablePreviewExt != ".exe" {
|
||||
t.Errorf("preview.disablePreviewExt should be '.exe', got '%s'", ud.Preview.DisablePreviewExt)
|
||||
}
|
||||
|
||||
// FileViewer fields
|
||||
if !ud.FileViewer.EditorQuickSave {
|
||||
t.Error("fileViewer.editorQuickSave should be true")
|
||||
}
|
||||
if ud.FileViewer.AutoplayMedia == nil || *ud.FileViewer.AutoplayMedia != true {
|
||||
t.Error("fileViewer.autoplayMedia should be true")
|
||||
}
|
||||
if ud.FileViewer.DisableViewingExt != ".bin" {
|
||||
t.Errorf("fileViewer.disableViewingExt should be '.bin', got '%s'", ud.FileViewer.DisableViewingExt)
|
||||
}
|
||||
if ud.FileViewer.DisableOnlyOfficeExt != ".md" {
|
||||
t.Errorf("fileViewer.disableOnlyOfficeExt should be '.md', got '%s'", ud.FileViewer.DisableOnlyOfficeExt)
|
||||
}
|
||||
if !ud.FileViewer.PreferEditorForMarkdown {
|
||||
t.Error("fileViewer.preferEditorForMarkdown should be true")
|
||||
}
|
||||
if !ud.FileViewer.DebugOffice {
|
||||
t.Error("fileViewer.debugOffice should be true")
|
||||
}
|
||||
|
||||
// Search fields
|
||||
if !ud.Search.DisableOptions {
|
||||
t.Error("search.disableOptions should be true")
|
||||
}
|
||||
|
||||
// UI fields
|
||||
if ud.UI.DarkMode == nil || *ud.UI.DarkMode != true {
|
||||
t.Error("ui.darkMode should be true")
|
||||
}
|
||||
if ud.UI.ThemeColor != "var(--red)" {
|
||||
t.Errorf("ui.themeColor should be 'var(--red)', got '%s'", ud.UI.ThemeColor)
|
||||
}
|
||||
if ud.UI.CustomTheme != "dark" {
|
||||
t.Errorf("ui.customTheme should be 'dark', got '%s'", ud.UI.CustomTheme)
|
||||
}
|
||||
if ud.UI.Locale != "de" {
|
||||
t.Errorf("ui.locale should be 'de', got '%s'", ud.UI.Locale)
|
||||
}
|
||||
|
||||
// Account fields
|
||||
if !ud.Account.LockPassword {
|
||||
t.Error("account.lockPassword should be true")
|
||||
}
|
||||
if !ud.Account.DisableSettings {
|
||||
t.Error("account.disableSettings should be true")
|
||||
}
|
||||
if ud.Account.LoginMethod != "oidc" {
|
||||
t.Errorf("account.loginMethod should be 'oidc', got '%s'", ud.Account.LoginMethod)
|
||||
}
|
||||
if !ud.Account.DisableUpdateNotifications {
|
||||
t.Error("account.disableUpdateNotifications should be true")
|
||||
}
|
||||
|
||||
// Account Permissions
|
||||
if !ud.Account.Permissions.Api {
|
||||
t.Error("account.permissions.api should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Admin {
|
||||
t.Error("account.permissions.admin should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Modify {
|
||||
t.Error("account.permissions.modify should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Share {
|
||||
t.Error("account.permissions.share should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Realtime {
|
||||
t.Error("account.permissions.realtime should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Delete {
|
||||
t.Error("account.permissions.delete should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Create {
|
||||
t.Error("account.permissions.create should be true")
|
||||
}
|
||||
if ud.Account.Permissions.Download == nil || *ud.Account.Permissions.Download != false {
|
||||
t.Error("account.permissions.download should be false")
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
|
||||
}
|
||||
|
||||
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
|
||||
func ConvertPermissionsToUsers(p UserDefaultsPermissions) users.Permissions {
|
||||
func ConvertPermissionsToUsers(p UserDefaultsAccountPermissions) users.Permissions {
|
||||
return users.Permissions{
|
||||
Api: p.Api,
|
||||
Admin: p.Admin,
|
||||
@@ -57,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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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, "/")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,8 @@ input,
|
||||
#search-input,
|
||||
.floating-tooltip,
|
||||
.settings-items>.item,
|
||||
.button-group {
|
||||
.button-group,
|
||||
.border-radius {
|
||||
border-radius: 0.25em !important;
|
||||
}
|
||||
|
||||
|
||||
+855
-94
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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:
|
||||
|
||||
Generated
+242
-224
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -416,7 +416,7 @@ export default {
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
this.closeTopPrompt(top.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user