mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-15 13:35:55 -05:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bfc397419 | ||
|
|
112740bdd4 | ||
|
|
9117fb2b5f | ||
|
|
668efec4d7 | ||
|
|
a5c81c8e2e | ||
|
|
88aa337696 | ||
|
|
9b48ce414e | ||
|
|
0723edf660 | ||
|
|
d2165ec0c3 | ||
|
|
78c554719f | ||
|
|
aa8f9e2923 | ||
|
|
3bfd9d9be0 |
No files matched your search
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "filebrowser",
|
||||
"image": "mcr.microsoft.com/devcontainers/go:1.25-bookworm",
|
||||
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
|
||||
"containerEnv": {
|
||||
"CGO_ENABLED": "1",
|
||||
"FILEBROWSER_DEVMODE": "true"
|
||||
},
|
||||
|
||||
"forwardPorts": [80],
|
||||
"portsAttributes": {
|
||||
"80": {
|
||||
"label": "filebrowser app",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
|
||||
"postCreateCommand": "make setup",
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-azuretools.vscode-docker"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
*.db
|
||||
*.db.old
|
||||
*.sqlite
|
||||
*.bak
|
||||
*.log
|
||||
*.mjs
|
||||
|
||||
@@ -2,6 +2,77 @@
|
||||
|
||||
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.3.9
|
||||
|
||||
**Security**:
|
||||
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
|
||||
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
|
||||
|
||||
**BugFixes**:
|
||||
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
|
||||
|
||||
## v1.3.8
|
||||
|
||||
**BugFixes**:
|
||||
- Quick download icon style after icon change.
|
||||
- Missing error popup for resource creatoin actions (upload/create)
|
||||
- EnforcedOtp login failure until restart (#2330)
|
||||
- Thumbnails for Folders only display sporadically (#2353)
|
||||
- Unwanted user scope change for users with non-default scopes (#2347)
|
||||
- Fix sidebar source info totals (#2321) (#2322) (#982)
|
||||
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
|
||||
|
||||
## v1.3.7
|
||||
|
||||
**Notes**:
|
||||
- creating/deleting password-based user requires reauthentication (#2112)
|
||||
|
||||
**BugFixes**:
|
||||
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
|
||||
- No Loginfields shown if password authentification is set to false (#2331)
|
||||
|
||||
## v1.3.6
|
||||
|
||||
**BugFixes**:
|
||||
- file watcher not working for files and not respecting line count (#2314)
|
||||
- "source" type link shows up multiple times when adding sidebar links if user has multiple sources.
|
||||
- JWT ES256 results in error (#2188)
|
||||
- single file shares not loading editor text content (#2122)
|
||||
|
||||
## v1.3.5
|
||||
|
||||
**Notes**:
|
||||
- any password change or admin user property change for a password user requires reauthentication (#2112)
|
||||
|
||||
**BugFixes**:
|
||||
- PWA icon fixes (#2292)
|
||||
- deny-rule'd folders visible in directory listings (regression from v1.2.4-stable) (#2295)
|
||||
- OTP and password requirement fixes (#2112) (#2263)
|
||||
- CLI not picking up config properly
|
||||
- Storage usage numbers on first load adjusted.
|
||||
- Some items in userDefaults are not setting defaults for new users properly #2278
|
||||
- Fix copy to clipboard and simplify code (#2281) (#2274)
|
||||
- Fix search shortcut `/` and pdf previews (#2307)
|
||||
- Special permissions bit not set when declared in createDirectoryPermission (#2283)
|
||||
- Execute permission altered after editing sh script with FB Quantum (#2309)
|
||||
- Source is red but working (#2289)
|
||||
|
||||
## v1.3.4
|
||||
|
||||
**New Features**:
|
||||
- Epub placementg url anchors to bookmark a specific location on the doc.
|
||||
- Add Requirement for Current Password When Changing Account Password (#2112)
|
||||
- Copy file path to clipborad through right click (#2204)
|
||||
|
||||
**Notes**:
|
||||
- user scope editing has path picker and filesystem validation
|
||||
|
||||
**BugFixes**:
|
||||
- Swiping media files doesn't respect Playback Queue (#2243)
|
||||
- Audio metadata and album thumbnails doesn't work (#2236)
|
||||
- Search spinner wouldn't show up sometimes while searching
|
||||
- The default landing page source should always be the first source in the sidebar, that wasn't always respected.
|
||||
|
||||
## v1.3.3
|
||||
|
||||
**New Features**:
|
||||
|
||||
@@ -18,5 +18,6 @@ COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser set -u testuser1,testuser1
|
||||
RUN ./filebrowser set rule -f /tests/playwright-files -p / -r user -v admin -allow -c config.yaml
|
||||
RUN ./filebrowser set rule -f /tests/playwright-files -p /excluded -r user -v admin -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p / -r user -v admin -allow -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
@@ -15,3 +15,4 @@ userDefaults:
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
share: true
|
||||
@@ -18,7 +18,7 @@ export default defineConfig({
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
retries: 1,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
@@ -53,8 +53,6 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
name: "light-screenshots",
|
||||
@@ -62,8 +60,6 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 3000,
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/settings",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
@@ -88,6 +90,111 @@ func Test_GetRealPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: PUT / save must not os.Chmod to PermFile for existing files, or +x (and other mode bits) are lost (#2309).
|
||||
func TestWriteFilePreservesExecutableBit(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
originalCache := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
|
||||
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_writefile_exec", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
scriptName := "backupscript.sh"
|
||||
scriptAbs := filepath.Join(root, scriptName)
|
||||
if err := os.WriteFile(scriptAbs, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName := "test_writefile_exec"
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: sourceName,
|
||||
Path: root,
|
||||
}, false, false)
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
stopPoll := startScannerStatusPoll(t, idx, 1)
|
||||
defer stopPoll()
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
indexPath := "/" + scriptName
|
||||
if err := WriteFile(sourceName, indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
st, err := os.Stat(scriptAbs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode()&0o111 == 0 {
|
||||
t.Fatalf("executable bits cleared after save: mode=%v", st.Mode())
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
}
|
||||
|
||||
func TestWriteFileRejectsExistingDirectory(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
originalCache := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
|
||||
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_writefile_isdir", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
dirName := "folder"
|
||||
if err := os.Mkdir(filepath.Join(root, dirName), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName := "test_writefile_isdir"
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: sourceName,
|
||||
Path: root,
|
||||
}, false, false)
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
stopPoll := startScannerStatusPoll(t, idx, 1)
|
||||
defer stopPoll()
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
err := WriteFile(sourceName, "/"+dirName, strings.NewReader("file content"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when path is an existing directory")
|
||||
}
|
||||
if !errors.Is(err, liberrors.ErrIsDirectory) {
|
||||
t.Fatalf("expected ErrIsDirectory, got: %v", err)
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
}
|
||||
|
||||
func TestSortItems(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -226,18 +226,17 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
|
||||
return response, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
|
||||
// Build response
|
||||
response.FileInfo = *info
|
||||
response.RealPath = filepath.Join(idx.Path, indexPath)
|
||||
response.Source = opts.Source
|
||||
|
||||
// Layer 3: FILTER CHILDREN (user access)
|
||||
// Remove child items THIS user can't access
|
||||
// otherwise response keeps unfiltered Folders/Files while CheckChildItemAccess only mutates info.
|
||||
if info.Type == "directory" {
|
||||
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
response.FileInfo = *info
|
||||
response.RealPath = filepath.Join(idx.Path, indexPath)
|
||||
response.Source = opts.Source
|
||||
if share != nil && user.Permissions.Share && opts.ShowSharedAttr {
|
||||
for i := range response.Files {
|
||||
file := &response.Files[i]
|
||||
@@ -272,47 +271,49 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
if isFolder {
|
||||
return
|
||||
}
|
||||
if opts.Metadata {
|
||||
if isVideo {
|
||||
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
info.Metadata = extItem.Metadata
|
||||
}
|
||||
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
|
||||
if exists {
|
||||
info.GetSubtitles(parentInfo)
|
||||
}
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
info.Subtitles = append(info.Subtitles, subtitles...)
|
||||
}
|
||||
|
||||
return
|
||||
if isVideo && !opts.Metadata {
|
||||
return
|
||||
}
|
||||
if isAudio && !(opts.Metadata || opts.AlbumArt) {
|
||||
return
|
||||
}
|
||||
if isVideo {
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
info.Metadata = extItem.Metadata
|
||||
}
|
||||
|
||||
if isAudio {
|
||||
// Create an ExtendedItemInfo to hold the metadata
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
// Copy metadata to ExtendedFileInfo
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
}
|
||||
return
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
|
||||
if exists {
|
||||
info.GetSubtitles(parentInfo)
|
||||
}
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
info.Subtitles = append(info.Subtitles, subtitles...)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Audio: run tag/album-art extraction when full metadata or preview (AlbumArt) is requested.
|
||||
// Preview uses AlbumArt without Metadata so we do not load arbitrary files as text content.
|
||||
if isAudio {
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Process text content for non-video, non-audio files
|
||||
@@ -760,22 +761,21 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyDefaultFilePerm := false
|
||||
var stat os.FileInfo
|
||||
// Check if the destination exists and is a directory
|
||||
if stat, err = os.Stat(realPath); err == nil {
|
||||
if stat.IsDir() {
|
||||
// If it's a directory and we're trying to create a file, remove the directory first
|
||||
err = os.RemoveAll(realPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove existing directory to create file: %v", err)
|
||||
}
|
||||
stat, err = os.Stat(realPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat file: %w", err)
|
||||
}
|
||||
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
|
||||
applyDefaultFilePerm = true
|
||||
} else if stat.IsDir() {
|
||||
return fmt.Errorf("%w: %q", errors.ErrIsDirectory, path)
|
||||
}
|
||||
|
||||
// Open the file for writing (create if it doesn't exist, truncate if it does)
|
||||
// For new files: permissions are set to fileutils.PermFile (subject to umask, which is usually acceptable)
|
||||
// For existing files: permissions are preserved automatically (O_TRUNC doesn't change them)
|
||||
// For new files: permissions are set to fileutils.PermFile (subject to umask, then Chmod bypasses umask)
|
||||
// For existing regular files: do not Chmod so saves preserve mode and special bits
|
||||
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -788,11 +788,11 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Explicitly set file permissions to bypass umask
|
||||
err = os.Chmod(realPath, fileutils.PermFile)
|
||||
if err != nil {
|
||||
// Handle chmod error gracefully
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
if applyDefaultFilePerm {
|
||||
err = os.Chmod(realPath, fileutils.PermFile)
|
||||
if err != nil {
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the file itself
|
||||
|
||||
@@ -19,7 +19,7 @@ func MakeUserDir(fullPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func MakeUserDirs(u *users.User, disableScopeChange bool) error {
|
||||
func MakeUserDirs(u *users.User, createDir bool) error {
|
||||
cleanedUserName := users.CleanUsername(u.Username)
|
||||
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
|
||||
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
|
||||
@@ -27,47 +27,31 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
|
||||
for i, scope := range u.Scopes {
|
||||
source, ok := settings.Config.Server.SourceMap[scope.Name]
|
||||
if !ok {
|
||||
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
continue
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
}
|
||||
// create directory and append user name
|
||||
if filepath.Base(scope.Scope) != cleanedUserName && !disableScopeChange {
|
||||
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
|
||||
parentDir := filepath.Join(source.Path, scope.Scope)
|
||||
fullPath := filepath.Join(source.Path, scope.Scope)
|
||||
parentDir := filepath.Dir(fullPath)
|
||||
if createDir {
|
||||
if source.Config.CreateUserDir && filepath.Base(scope.Scope) != cleanedUserName {
|
||||
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
|
||||
fullPath = filepath.Join(fullPath, cleanedUserName)
|
||||
}
|
||||
if !Exists(parentDir) {
|
||||
if err := MakeUserDir(parentDir); err != nil {
|
||||
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
|
||||
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
// Continue to next scope even if this one failed
|
||||
}
|
||||
} else if filepath.Base(scope.Scope) == cleanedUserName {
|
||||
// create directory exactly as specified
|
||||
fullPath := filepath.Join(source.Path, scope.Scope)
|
||||
parentDir := filepath.Dir(fullPath)
|
||||
if !Exists(parentDir) {
|
||||
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
|
||||
continue
|
||||
}
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
logger.Errorf("create user: failed to create user home dir: %s", err)
|
||||
// Continue to next scope even if this one failed
|
||||
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
}
|
||||
} else {
|
||||
// just assigning scope to path provided, so just check that it exists
|
||||
path := filepath.Join(source.Path, scope.Scope)
|
||||
if !Exists(path) {
|
||||
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
|
||||
continue
|
||||
if !Exists(fullPath) {
|
||||
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
u.Scopes[i] = scope
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -12,9 +12,45 @@ import (
|
||||
var PermFile os.FileMode
|
||||
var PermDir os.FileMode
|
||||
|
||||
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
|
||||
PermFile = PermFileOctal
|
||||
PermDir = PermDirOctal
|
||||
// SetFsPermissions sets create modes from Unix chmod(2)-style values (for example
|
||||
// values produced by strconv.ParseUint(s, 8, 32)). setuid, setgid, and sticky
|
||||
// bits must be converted for Go's os.FileMode; a plain cast from Unix octal is
|
||||
// incorrect for those bits.
|
||||
func SetFsPermissions(unixFileMode, unixDirMode uint32) {
|
||||
PermFile = unixModeToFileMode(unixFileMode)
|
||||
PermDir = unixModeToFileMode(unixDirMode)
|
||||
}
|
||||
|
||||
// EffectiveDirPerm returns [PermDir] if [SetFsPermissions] was called, otherwise 0o755. On Unix,
|
||||
// [os.Mkdir] with perm 0 fails with permission denied, so use this (or [PermFile]) for creates when
|
||||
// the process has not set globals yet.
|
||||
func EffectiveDirPerm() os.FileMode {
|
||||
if PermDir == 0 {
|
||||
return 0o755
|
||||
}
|
||||
return PermDir
|
||||
}
|
||||
|
||||
// EffectiveFilePerm returns [PermFile] if set, otherwise 0o644.
|
||||
func EffectiveFilePerm() os.FileMode {
|
||||
if PermFile == 0 {
|
||||
return 0o644
|
||||
}
|
||||
return PermFile
|
||||
}
|
||||
|
||||
func unixModeToFileMode(u uint32) os.FileMode {
|
||||
m := os.FileMode(u & 0777)
|
||||
if u&0o4000 != 0 {
|
||||
m |= os.ModeSetuid
|
||||
}
|
||||
if u&0o2000 != 0 {
|
||||
m |= os.ModeSetgid
|
||||
}
|
||||
if u&0o1000 != 0 {
|
||||
m |= os.ModeSticky
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// MoveFile moves a file from src to dst.
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
package fileutils
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnixModeToFileMode_setgid(t *testing.T) {
|
||||
m := unixModeToFileMode(0o2770)
|
||||
if got := m.Perm(); got != 0o770 {
|
||||
t.Fatalf("Perm() = %#o, want 0770", got)
|
||||
}
|
||||
if m&os.ModeSetgid == 0 {
|
||||
t.Fatal("expected ModeSetgid")
|
||||
}
|
||||
if m&os.ModeSetuid != 0 || m&os.ModeSticky != 0 {
|
||||
t.Fatalf("unexpected special bits: %#o", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonPrefix(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
|
||||
// JSONAuth is a json implementation of an Auther.
|
||||
type JSONAuth struct {
|
||||
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
|
||||
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
|
||||
DisableOtp bool `json:"disableOtp" yaml:"disableOtp"`
|
||||
}
|
||||
|
||||
// Auth authenticates the user via a json in content body.
|
||||
@@ -23,12 +24,12 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
|
||||
username := r.URL.Query().Get("username")
|
||||
recaptcha := r.URL.Query().Get("recaptcha")
|
||||
password := r.Header.Get("X-Password")
|
||||
totpCode := r.Header.Get("X-Secret")
|
||||
// URL-decode password to support special characters in headers
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid password encoding: %v", err)
|
||||
}
|
||||
totpCode := r.Header.Get("X-Secret")
|
||||
|
||||
// If ReCaptcha is enabled, check the code.
|
||||
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
|
||||
@@ -58,8 +59,7 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
}
|
||||
// check for OTP for password
|
||||
if user.TOTPSecret != "" {
|
||||
if user.TOTPSecret != "" && !auther.DisableOtp {
|
||||
if totpCode == "" {
|
||||
return nil, errors.ErrNoTotpProvided
|
||||
}
|
||||
|
||||
+102
-7
@@ -1,7 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
@@ -47,14 +52,12 @@ func VerifyExternalJWT(tokenString string, secret string, algorithm string, user
|
||||
if expectedMethod == nil {
|
||||
return nil, fmt.Errorf("unsupported signing algorithm: %s", algorithm)
|
||||
}
|
||||
|
||||
|
||||
if token.Method.Alg() != expectedMethod.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// Return the secret key for HMAC algorithms
|
||||
// For RSA/ECDSA, this would need to be a public key
|
||||
return []byte(secret), nil
|
||||
|
||||
return jwtVerificationKey(secret, token.Method, algorithm)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -100,10 +103,102 @@ func VerifyExternalJWT(tokenString string, secret string, algorithm string, user
|
||||
return username, claimsMap, nil
|
||||
}
|
||||
|
||||
func jwtVerificationKey(secret string, method jwt.SigningMethod, algorithm string) (interface{}, error) {
|
||||
switch method.(type) {
|
||||
case *jwt.SigningMethodHMAC:
|
||||
return []byte(secret), nil
|
||||
case *jwt.SigningMethodRSA:
|
||||
key, err := parsePublicKeyFromPEM(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("JWT secret must contain an RSA public key in PEM format for algorithm %s", algorithm)
|
||||
}
|
||||
return rsaKey, nil
|
||||
case *jwt.SigningMethodECDSA:
|
||||
key, err := parsePublicKeyFromPEM(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ecKey, ok := key.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("JWT secret must contain an ECDSA public key in PEM format for algorithm %s", algorithm)
|
||||
}
|
||||
if err := ecdsaCurveMatchesAlgorithm(algorithm, ecKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ecKey, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported JWT signing method: %s", method.Alg())
|
||||
}
|
||||
}
|
||||
|
||||
func ecdsaCurveMatchesAlgorithm(algorithm string, pub *ecdsa.PublicKey) error {
|
||||
var want elliptic.Curve
|
||||
switch algorithm {
|
||||
case "ES256":
|
||||
want = elliptic.P256()
|
||||
case "ES384":
|
||||
want = elliptic.P384()
|
||||
case "ES512":
|
||||
want = elliptic.P521()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if pub.Curve != want {
|
||||
return fmt.Errorf("ECDSA public key curve does not match algorithm %s", algorithm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePublicKeyFromPEM(pemData string) (interface{}, error) {
|
||||
rest := []byte(pemData)
|
||||
var lastErr error
|
||||
for len(rest) > 0 {
|
||||
block, rem := pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
rest = rem
|
||||
|
||||
var key interface{}
|
||||
var err error
|
||||
switch block.Type {
|
||||
case "CERTIFICATE":
|
||||
var cert *x509.Certificate
|
||||
cert, err = x509.ParseCertificate(block.Bytes)
|
||||
if err == nil {
|
||||
key = cert.PublicKey
|
||||
}
|
||||
case "PUBLIC KEY":
|
||||
key, err = x509.ParsePKIXPublicKey(block.Bytes)
|
||||
case "RSA PUBLIC KEY":
|
||||
key, err = x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
case "EC PUBLIC KEY":
|
||||
key, err = x509.ParsePKIXPublicKey(block.Bytes)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if key != nil {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT public key from PEM: %w", lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("no public key found in JWT secret (expected PEM: CERTIFICATE, PUBLIC KEY, RSA PUBLIC KEY, or EC PUBLIC KEY)")
|
||||
}
|
||||
|
||||
// ExtractGroupsFromClaims extracts groups from JWT claims based on the configured groups claim field
|
||||
func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField string) []string {
|
||||
var groups []string
|
||||
|
||||
|
||||
if groupsVal, ok := claims[groupsClaimField]; ok {
|
||||
switch v := groupsVal.(type) {
|
||||
case []interface{}:
|
||||
@@ -129,6 +224,6 @@ func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -144,6 +152,140 @@ func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ES256_PublicKeyPEM(t *testing.T) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
|
||||
username := "ec-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "ES256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_RS256_PublicKeyPEM(t *testing.T) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
|
||||
username := "rsa-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "RS256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ES256_WrongCurveRejected(t *testing.T) {
|
||||
p256Priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
p384Priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
verifyDER, err := x509.MarshalPKIXPublicKey(&p384Priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
verifyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: verifyDER}))
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "u",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(p256Priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, verifyPEM, "ES256", "sub")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PEM public key curve does not match algorithm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_CertificatePEM(t *testing.T) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "jwt-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
||||
|
||||
username := "cert-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, certPEM, "ES256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_ArrayOfStrings(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []string{"admin", "users"},
|
||||
|
||||
@@ -146,14 +146,14 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
|
||||
}
|
||||
|
||||
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
|
||||
// get data from cache
|
||||
cachedSecret, found := TotpCache.Get(user.Username)
|
||||
if !found && user.TOTPSecret == "" {
|
||||
return fmt.Errorf("OTP token not found in cache, please generate a new one")
|
||||
}
|
||||
useCache := found && user.TOTPSecret == ""
|
||||
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
|
||||
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
|
||||
if found {
|
||||
if useCache {
|
||||
splitSecret := strings.Split(cachedSecret, "||")
|
||||
if len(splitSecret) < 2 {
|
||||
return fmt.Errorf("invalid cached OTP token format")
|
||||
@@ -173,9 +173,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
}
|
||||
secretToValidate = decryptedSecret
|
||||
}
|
||||
// --- END: ADD THIS DECRYPTION LOGIC ---
|
||||
|
||||
// Validate the token using the (now plaintext) secret.
|
||||
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
|
||||
Period: TOTPPeriod,
|
||||
Skew: TOTPSkew,
|
||||
@@ -198,6 +196,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
logger.Error("error updating user with OTP token:", err)
|
||||
return fmt.Errorf("error updating user with OTP token: %w", err)
|
||||
}
|
||||
TotpCache.Delete(user.Username)
|
||||
} else {
|
||||
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
|
||||
}
|
||||
|
||||
+70
-60
@@ -23,7 +23,7 @@ var (
|
||||
)
|
||||
|
||||
// return bool to indicate if the program should continue running
|
||||
func runCLI() bool {
|
||||
func runCLI() (bool, bool) {
|
||||
|
||||
generateYaml()
|
||||
|
||||
@@ -53,85 +53,100 @@ func runCLI() bool {
|
||||
// Show help if requested
|
||||
if help {
|
||||
generalUsage()
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Create a new FlagSet for the 'set' subcommand
|
||||
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
|
||||
var user, dbConfig string
|
||||
var asAdmin bool
|
||||
var fsPath, indexPath, ruleCategory, value string
|
||||
var sourceName, indexPath, ruleCategory, value string
|
||||
var allow bool
|
||||
|
||||
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
|
||||
setCmd.BoolVar(&asAdmin, "a", false, "Create a user as admin user, used in combination with -u")
|
||||
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
|
||||
setCmd.StringVar(&fsPath, "f", "", "Real filesystem path (e.g. /mnt/storage) for rule command")
|
||||
setCmd.StringVar(&sourceName, "s", "", "source name to apply the rule to")
|
||||
setCmd.StringVar(&indexPath, "p", "", "Index path (e.g. /secret) for rule command")
|
||||
setCmd.StringVar(&ruleCategory, "r", "", "Rule category: 'user', 'group', or 'all' (for deny only)")
|
||||
setCmd.StringVar(&value, "v", "", "Value: username or groupname (not required if ruleCategory is 'all')")
|
||||
setCmd.BoolVar(&allow, "allow", false, "Allow access (default: false, which means deny)")
|
||||
|
||||
// Parse subcommand flags only if a subcommand is specified
|
||||
storeCfg := configPath
|
||||
cliSubcmd := ""
|
||||
var setKind string
|
||||
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "setup":
|
||||
createConfig(configPath)
|
||||
return false
|
||||
case "set":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
subcommand := os.Args[2]
|
||||
|
||||
// Handle old syntax: "set -u user,pass -c config.yaml"
|
||||
if subcommand == "-u" {
|
||||
// Parse flags starting from os.Args[2:] to include the -u flag
|
||||
err := setCmd.Parse(os.Args[2:])
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
|
||||
err = setUser(dbConfig, asAdmin)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// New pattern: subcommand-based (e.g., "set user" or "set rule")
|
||||
// Parse flags starting from os.Args[3:] to skip the subcommand
|
||||
err := setCmd.Parse(os.Args[3:])
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
switch subcommand {
|
||||
case "rule":
|
||||
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
default:
|
||||
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", subcommand)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
case "version":
|
||||
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
|
||||
Version : %v
|
||||
Commit : %v
|
||||
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
|
||||
`, version.Version, version.CommitSHA, version.Version)
|
||||
return false
|
||||
`, version.Version, version.CommitSHA, version.Version)
|
||||
return false, false
|
||||
case "setup":
|
||||
cliSubcmd = "setup"
|
||||
case "set":
|
||||
cliSubcmd = "set"
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
setKind = os.Args[2]
|
||||
var err error
|
||||
// Handle old syntax: "set -u user,pass -c config.yaml"
|
||||
if setKind == "-u" {
|
||||
err = setCmd.Parse(os.Args[2:])
|
||||
} else {
|
||||
// New pattern: subcommand-based (e.g., "set user" or "set rule")
|
||||
err = setCmd.Parse(os.Args[3:])
|
||||
}
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
storeCfg = dbConfig
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
dbExists := getStore(storeCfg)
|
||||
|
||||
switch cliSubcmd {
|
||||
case "setup":
|
||||
createConfig(configPath)
|
||||
return false, dbExists
|
||||
case "set":
|
||||
if setKind == "-u" {
|
||||
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
|
||||
err := setUser(dbConfig, asAdmin)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false, dbExists
|
||||
}
|
||||
switch setKind {
|
||||
case "rule":
|
||||
sourceInfo, ok := settings.Config.Server.NameToSource[sourceName] // backend source is path
|
||||
if !ok {
|
||||
fmt.Printf("error: invalid source name: %s\n", sourceName)
|
||||
os.Exit(1)
|
||||
}
|
||||
err := setRule(dbConfig, sourceInfo.Path, indexPath, ruleCategory, value, allow)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false, dbExists
|
||||
default:
|
||||
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", setKind)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false, dbExists
|
||||
default:
|
||||
return true, dbExists
|
||||
}
|
||||
}
|
||||
|
||||
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
|
||||
@@ -148,10 +163,6 @@ func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool
|
||||
if ruleCategory != "all" && value == "" {
|
||||
return fmt.Errorf("value is required when ruleCategory is 'user' or 'group': use -v <username|groupname>")
|
||||
}
|
||||
|
||||
// Initialize store and settings
|
||||
_ = getStore(dbConfig) // ignore bool check
|
||||
|
||||
// Apply the rule based on allow flag and ruleCategory
|
||||
var err error
|
||||
if allow {
|
||||
@@ -202,7 +213,6 @@ func setUser(dbConfig string, asAdmin bool) error {
|
||||
}
|
||||
username := userInfo[0]
|
||||
password := userInfo[1]
|
||||
_ = getStore(dbConfig) // ignore bool check
|
||||
user, err := store.Users.Get(username)
|
||||
if err != nil {
|
||||
newUser := users.User{
|
||||
|
||||
+5
-6
@@ -54,10 +54,14 @@ commands:
|
||||
}
|
||||
|
||||
func StartFilebrowser() {
|
||||
keepGoing := runCLI()
|
||||
keepGoing, dbExists := runCLI()
|
||||
if !keepGoing {
|
||||
return
|
||||
}
|
||||
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
|
||||
if !dbExists {
|
||||
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
|
||||
}
|
||||
if !settings.Config.Server.DisableUpdateCheck {
|
||||
info, _ := utils.CheckForUpdates()
|
||||
if info.LatestVersion != "" {
|
||||
@@ -76,11 +80,6 @@ func StartFilebrowser() {
|
||||
|
||||
done := make(chan struct{}) // Signals server has stopped
|
||||
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
|
||||
dbExists := getStore(configPath)
|
||||
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
|
||||
if !dbExists {
|
||||
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
|
||||
}
|
||||
|
||||
// Dev mode enables development features like template hot-reloading
|
||||
_, err := os.Stat("http/dist")
|
||||
|
||||
+6
-4
@@ -46,8 +46,7 @@ func validateUserInfo(newDB bool) {
|
||||
}
|
||||
adminUser := settings.Config.Auth.AdminUsername
|
||||
adminPass := settings.Config.Auth.AdminPassword
|
||||
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
|
||||
if user.Username == adminUser && adminPass != "" && passwordEnabled {
|
||||
if user.Username == adminUser && adminPass != "" && user.LoginMethod == users.LoginMethodPassword {
|
||||
logger.Info("Resetting admin user to default username and password.")
|
||||
user.Permissions.Admin = true
|
||||
user.Password = settings.Config.Auth.AdminPassword
|
||||
@@ -63,7 +62,11 @@ func validateUserInfo(newDB bool) {
|
||||
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
|
||||
}
|
||||
}
|
||||
err := store.Users.Save(user, changePass, true)
|
||||
fields := []string{"Scopes", "SidebarLinks", "Tokens", "Permissions", "Preview", "ShowFirstLogin", "LoginMethod", "Version"}
|
||||
if changePass {
|
||||
fields = append(fields, "Password")
|
||||
}
|
||||
err := store.Users.Update(user, true, fields...)
|
||||
if err != nil {
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
}
|
||||
@@ -94,7 +97,6 @@ func updateUserScopes(user *users.User) bool {
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: src.Path,
|
||||
Scope: existingScope.Scope,
|
||||
|
||||
@@ -19,7 +19,7 @@ var (
|
||||
ErrInvalidRequestParams = errors.New("invalid request params")
|
||||
ErrSourceIsParent = errors.New("source is parent")
|
||||
ErrNoTotpProvided = errors.New("OTP code is required for user")
|
||||
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
|
||||
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not configured")
|
||||
ErrUnauthorized = errors.New("user unauthorized")
|
||||
ErrNotIndexed = errors.New("directory or item excluded from indexing")
|
||||
ErrNotViewable = errors.New("directory or item is not viewable")
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// TestApplyUserDefaults_copiesUserDefaultsOntoUser guards against drift between
|
||||
// [UserDefaults] and [ApplyUserDefaults] (regression for #2278-style bugs).
|
||||
func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
dark := false
|
||||
dlFalse := false
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{
|
||||
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,
|
||||
},
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Api: true,
|
||||
Admin: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Realtime: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Download: &dlFalse,
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
DisableHideSidebar: true,
|
||||
Image: boolPtr(false),
|
||||
Video: boolPtr(false),
|
||||
Audio: boolPtr(false),
|
||||
MotionVideoPreview: boolPtr(false),
|
||||
Office: boolPtr(false),
|
||||
PopUp: boolPtr(false),
|
||||
AutoplayMedia: boolPtr(false),
|
||||
DefaultMediaPlayer: true,
|
||||
Folder: boolPtr(false),
|
||||
Models: boolPtr(false),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
u := &users.User{Username: "alice"}
|
||||
ApplyUserDefaults(u)
|
||||
|
||||
want := users.User{
|
||||
DisableSettings: true,
|
||||
LockPassword: true,
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
Permissions: users.Permissions{
|
||||
Api: true,
|
||||
Admin: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Realtime: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Download: false,
|
||||
},
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
EditorQuickSave: true,
|
||||
HideSidebarFileActions: true,
|
||||
DisableQuickToggles: true,
|
||||
DisableSearchOptions: true,
|
||||
StickySidebar: false,
|
||||
HideFilesInTree: true,
|
||||
DarkMode: false,
|
||||
Locale: "de",
|
||||
ViewMode: "list",
|
||||
SingleClick: true,
|
||||
ShowHidden: true,
|
||||
DateFormat: true,
|
||||
GallerySize: 7,
|
||||
ThemeColor: "var(--red)",
|
||||
QuickDownload: true,
|
||||
DisablePreviewExt: ".exe",
|
||||
DisableViewingExt: ".bat",
|
||||
DisableUpdateNotifications: true,
|
||||
DisableOfficePreviewExt: "legacy",
|
||||
DisableOnlyOfficeExt: ".x",
|
||||
CustomTheme: "Night",
|
||||
ShowSelectMultiple: true,
|
||||
DebugOffice: true,
|
||||
DeleteWithoutConfirming: true,
|
||||
DeleteAfterArchive: false,
|
||||
PreferEditorForMarkdown: true,
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 3,
|
||||
UploadChunkSize: 5,
|
||||
DownloadChunkSize: 2,
|
||||
},
|
||||
Preview: users.Preview{
|
||||
DisableHideSidebar: true,
|
||||
Image: false,
|
||||
Video: false,
|
||||
Audio: false,
|
||||
MotionVideoPreview: false,
|
||||
Office: false,
|
||||
PopUp: false,
|
||||
AutoplayMedia: false,
|
||||
DefaultMediaPlayer: true,
|
||||
Folder: false,
|
||||
Models: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want.Permissions, u.Permissions); diff != "" {
|
||||
t.Fatalf("Permissions mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(want.NonAdminEditable, u.NonAdminEditable); diff != "" {
|
||||
t.Fatalf("NonAdminEditable mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if u.DisableSettings != want.DisableSettings {
|
||||
t.Fatalf("DisableSettings: got %v want %v", u.DisableSettings, want.DisableSettings)
|
||||
}
|
||||
if u.LockPassword != want.LockPassword {
|
||||
t.Fatalf("LockPassword: got %v want %v", u.LockPassword, want.LockPassword)
|
||||
}
|
||||
if u.LoginMethod != want.LoginMethod {
|
||||
t.Fatalf("LoginMethod: got %q want %q", u.LoginMethod, want.LoginMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUserDefaults_setsLoginMethodWhenEmpty(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{LoginMethod: "jwt"},
|
||||
}
|
||||
u := &users.User{Username: "x"}
|
||||
ApplyUserDefaults(u)
|
||||
if u.LoginMethod != users.LoginMethodJwt {
|
||||
t.Fatalf("LoginMethod: got %q want jwt", u.LoginMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUserDefaults_preservesLoginMethodWhenAlreadySet(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{LoginMethod: "password"},
|
||||
}
|
||||
u := &users.User{Username: "x", LoginMethod: users.LoginMethodProxy}
|
||||
ApplyUserDefaults(u)
|
||||
if u.LoginMethod != users.LoginMethodProxy {
|
||||
t.Fatalf("LoginMethod: got %q want proxy", u.LoginMethod)
|
||||
}
|
||||
}
|
||||
@@ -106,12 +106,12 @@ type LdapConfig struct {
|
||||
}
|
||||
|
||||
// JwtAuthConfig configures external JWT token authentication
|
||||
// Similar to Grafana's JWT auth: accepts external JWT tokens signed with a shared secret
|
||||
// Similar to Grafana's JWT auth: verifies HS* tokens with a shared secret, or RS*/ES* with a PEM public key (or certificate)
|
||||
// The query parameter is hardcoded to "jwt" (e.g. ?jwt=<token>)
|
||||
type JwtAuthConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
|
||||
Secret string `json:"secret"` // secret: shared secret key for verifying JWT token signatures (required)
|
||||
Secret string `json:"secret"` // Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
|
||||
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ func setupFs() {
|
||||
Config.Server.Filesystem.CreateDirectoryPermission = "755"
|
||||
dirPermOctal, _ = strconv.ParseUint("755", 8, 32)
|
||||
}
|
||||
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
|
||||
fileutils.SetFsPermissions(uint32(filePermOctal), uint32(dirPermOctal))
|
||||
|
||||
// Perform mandatory cache directory speed test
|
||||
testCacheDirSpeed()
|
||||
@@ -817,6 +817,13 @@ func loadCustomFavicon() {
|
||||
Env.FaviconPath = validatedPath
|
||||
Env.FaviconIsCustom = true
|
||||
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
|
||||
// PWA and platform PNGs are generated at server startup from this path (SVG uses a raster
|
||||
// sidecar next to the .svg when present; see icons.GeneratePWAIcons).
|
||||
}
|
||||
|
||||
// PWAIconsCacheDir is where startup icon generation writes PNGs (under the server cache dir).
|
||||
func PWAIconsCacheDir() string {
|
||||
return filepath.Join(Config.Server.CacheDir, "icons")
|
||||
}
|
||||
|
||||
func loadLoginIcon() {
|
||||
|
||||
@@ -65,53 +65,67 @@ func AdminPerms() users.Permissions {
|
||||
}
|
||||
|
||||
// Apply applies the default options to a user.
|
||||
// Keep this in sync with [UserDefaults]: every user-facing field there should be copied
|
||||
// here (except DefaultScopes, which is wired through source config rather than this helper).
|
||||
func ApplyUserDefaults(u *users.User) {
|
||||
u.StickySidebar = Config.UserDefaults.StickySidebar
|
||||
u.DisableSettings = Config.UserDefaults.DisableSettings
|
||||
u.HideFilesInTree = Config.UserDefaults.HideFilesInTree
|
||||
u.DeleteAfterArchive = Config.UserDefaults.DeleteAfterArchive
|
||||
d := Config.UserDefaults
|
||||
|
||||
// Handle DarkMode with default - dereference pointer from config
|
||||
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
|
||||
u.DisableSettings = d.DisableSettings
|
||||
u.LockPassword = d.LockPassword
|
||||
|
||||
u.Locale = Config.UserDefaults.Locale
|
||||
u.ViewMode = Config.UserDefaults.ViewMode
|
||||
u.SingleClick = Config.UserDefaults.SingleClick
|
||||
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
|
||||
|
||||
// Handle Permissions - convert from pointer-based defaults to regular bools
|
||||
u.Permissions.Api = Config.UserDefaults.Permissions.Api
|
||||
u.Permissions.Admin = Config.UserDefaults.Permissions.Admin
|
||||
u.Permissions.Modify = Config.UserDefaults.Permissions.Modify
|
||||
u.Permissions.Share = Config.UserDefaults.Permissions.Share
|
||||
u.Permissions.Realtime = Config.UserDefaults.Permissions.Realtime
|
||||
u.Permissions.Delete = Config.UserDefaults.Permissions.Delete
|
||||
u.Permissions.Create = Config.UserDefaults.Permissions.Create
|
||||
u.Permissions.Download = boolValueOrDefault(Config.UserDefaults.Permissions.Download, true)
|
||||
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)
|
||||
|
||||
// Handle Preview - convert from pointer-based defaults to regular bools
|
||||
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
|
||||
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
|
||||
u.Preview.Audio = boolValueOrDefault(Config.UserDefaults.Preview.Audio, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
|
||||
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
|
||||
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
|
||||
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
|
||||
u.Preview.Models = boolValueOrDefault(Config.UserDefaults.Preview.Models, true)
|
||||
u.Preview.DisableHideSidebar = d.Preview.DisableHideSidebar
|
||||
u.Preview.Image = boolValueOrDefault(d.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(d.Preview.Video, true)
|
||||
u.Preview.Audio = boolValueOrDefault(d.Preview.Audio, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(d.Preview.MotionVideoPreview, true)
|
||||
u.Preview.Office = boolValueOrDefault(d.Preview.Office, true)
|
||||
u.Preview.PopUp = boolValueOrDefault(d.Preview.PopUp, true)
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(d.Preview.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = d.Preview.DefaultMediaPlayer
|
||||
u.Preview.Folder = boolValueOrDefault(d.Preview.Folder, true)
|
||||
u.Preview.Models = boolValueOrDefault(d.Preview.Models, true)
|
||||
|
||||
if u.LoginMethod == "" && d.LoginMethod != "" {
|
||||
u.LoginMethod = users.LoginMethod(d.LoginMethod)
|
||||
}
|
||||
|
||||
u.ShowHidden = Config.UserDefaults.ShowHidden
|
||||
u.DateFormat = Config.UserDefaults.DateFormat
|
||||
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
|
||||
u.ThemeColor = Config.UserDefaults.ThemeColor
|
||||
u.GallerySize = Config.UserDefaults.GallerySize
|
||||
u.QuickDownload = Config.UserDefaults.QuickDownload
|
||||
u.LockPassword = Config.UserDefaults.LockPassword
|
||||
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
|
||||
u.FileLoading = Config.UserDefaults.FileLoading
|
||||
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
|
||||
u.PreferEditorForMarkdown = Config.UserDefaults.PreferEditorForMarkdown
|
||||
if len(u.Scopes) == 0 && u.Username != "anonymous" {
|
||||
for _, source := range Config.Server.Sources {
|
||||
if source.Config.DefaultEnabled {
|
||||
|
||||
@@ -36,10 +36,6 @@ type Environment struct {
|
||||
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
|
||||
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
|
||||
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
|
||||
PWAIconsDir string `json:"-"` // directory where generated PWA icons are stored
|
||||
PWAIcon192 string `json:"-"` // path to 192x192 PWA icon
|
||||
PWAIcon256 string `json:"-"` // path to 256x256 PWA icon
|
||||
PWAIcon512 string `json:"-"` // path to 512x512 PWA icon
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -214,7 +210,7 @@ type SourceConfig struct {
|
||||
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
|
||||
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
|
||||
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
|
||||
CreateUserDir bool `json:"createUserDir"` // deprecated: create a user directory for each user under defaultUserScope + username (always true now)
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
|
||||
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
// hidden but used internally - optimized map lookups for conditional rules
|
||||
ResolvedRules ResolvedRulesConfig `json:"-"`
|
||||
|
||||
@@ -17,6 +17,11 @@ type IndexInfo struct {
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
|
||||
// Disk usage (cached from periodic GetIndexInfo / partition probes; aligns with ReducedIndex Stats)
|
||||
UsedAsIndexed uint64 `json:"used"`
|
||||
UsedDisk uint64 `json:"usedAlt"`
|
||||
DiskTotal uint64 `json:"total"`
|
||||
|
||||
// Scanner information - map of scanner path to scanner stats
|
||||
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
|
||||
}
|
||||
|
||||
@@ -298,28 +298,70 @@ func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) erro
|
||||
return err
|
||||
}
|
||||
|
||||
// dbTransientOpAttempts is how many times to run a bulk DB op that may hit SQLITE_BUSY before giving up.
|
||||
const dbTransientOpAttempts = 2
|
||||
|
||||
const dbTransientRetryBackoff = 50 * time.Millisecond
|
||||
|
||||
// BulkInsertItems inserts multiple items in a single transaction for a specific source.
|
||||
// Database errors (busy/locked) are treated as soft failures - the filesystem is the source of truth.
|
||||
// Returns nil on success or soft failure (busy/locked), error only on hard failures.
|
||||
// Transient busy/locked errors retry once before giving up (see dbTransientOpAttempts).
|
||||
func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
for attempt := 1; attempt <= dbTransientOpAttempts; attempt++ {
|
||||
ok, transient, err := db.attemptBulkInsert(source, items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
if attempt > 1 {
|
||||
logger.Infof("[DB_TX] BulkInsertItems: succeeded on retry (%d items, attempt %d, %v)",
|
||||
len(items), attempt, time.Since(startTime))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if transient && attempt < dbTransientOpAttempts {
|
||||
logger.Warningf("[DB_TX] BulkInsertItems: transient DB error (attempt %d/%d, %d items), retrying after %v",
|
||||
attempt, dbTransientOpAttempts, len(items), dbTransientRetryBackoff)
|
||||
time.Sleep(dbTransientRetryBackoff)
|
||||
continue
|
||||
}
|
||||
if transient {
|
||||
logger.Warningf("[DB_TX] BulkInsertItems: still failing after %d attempts (%d items, %v) — batch dropped",
|
||||
dbTransientOpAttempts, len(items), time.Since(startTime))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil // defensive if dbTransientOpAttempts is ever 0 and loop never runs
|
||||
}
|
||||
|
||||
// attemptBulkInsert runs one transactional bulk insert.
|
||||
// Returns success=true when committed; transient=true when SQLITE_BUSY-like errors allow a retry (mutex released).
|
||||
func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo) (success bool, transient bool, err error) {
|
||||
tx, err := db.BeginTransaction()
|
||||
if err != nil {
|
||||
// Soft failure: DB is busy or locked, skip this update
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
db.mu.Unlock() // Release mutex on error
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked), skipping - took %v", time.Since(startTime))
|
||||
return nil // Non-fatal: filesystem will be used as fallback
|
||||
db.mu.Unlock()
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked): %v", err)
|
||||
return false, true, nil
|
||||
}
|
||||
db.mu.Unlock() // Release mutex on error
|
||||
return err // Hard failure: something is wrong with the DB
|
||||
db.mu.Unlock()
|
||||
return false, false, err
|
||||
}
|
||||
defer db.mu.Unlock() // Ensure mutex is always released
|
||||
|
||||
// Always update all fields including last_updated, even if nothing changed
|
||||
// This ensures last_updated stays current for stale entry detection
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
db.mu.Unlock()
|
||||
}()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -336,14 +378,12 @@ func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) er
|
||||
`)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
// With EXCLUSIVE locking and mutex, this shouldn't happen.
|
||||
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
|
||||
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Prepare: %v", err)
|
||||
return nil
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: Prepare busy/locked: %v", err)
|
||||
return false, true, nil
|
||||
}
|
||||
return err
|
||||
return false, false, err
|
||||
}
|
||||
defer stmt.Close() // Ensure statement is always closed
|
||||
defer stmt.Close()
|
||||
|
||||
nowUnix := time.Now().Unix()
|
||||
for _, info := range items {
|
||||
@@ -363,28 +403,23 @@ func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) er
|
||||
)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
// With EXCLUSIVE locking and mutex, this shouldn't happen.
|
||||
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
|
||||
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Exec: %v", err)
|
||||
return nil
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: Exec busy/locked during insert: %v", err)
|
||||
return false, true, nil
|
||||
}
|
||||
logger.Errorf("[DB] BulkInsertItems: Exec failed for path=%s, source=%s, error=%v", info.Path, source, err)
|
||||
return err
|
||||
return false, false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Try to commit
|
||||
if err := tx.Commit(); err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
// With EXCLUSIVE locking and mutex, this shouldn't happen.
|
||||
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
|
||||
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Commit: %v", err)
|
||||
return nil
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: Commit busy/locked: %v", err)
|
||||
return false, true, nil
|
||||
}
|
||||
return err
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
return nil
|
||||
committed = true
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
// isBusyError checks if an error is SQLITE_BUSY (error code 5)
|
||||
@@ -474,51 +509,6 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetHasPreviewBatch retrieves hasPreview status for multiple directory paths in a single query.
|
||||
// This is optimized for the N+1 query pattern in GetDirInfo where we need hasPreview for all subdirectories.
|
||||
// Returns map[path]hasPreview. Missing paths in the result indicate they're not in the database yet.
|
||||
func (db *IndexDB) GetHasPreviewBatch(source string, paths []string) (map[string]bool, error) {
|
||||
if len(paths) == 0 {
|
||||
return make(map[string]bool), nil
|
||||
}
|
||||
|
||||
// Build query with IN clause - only select path and has_preview for efficiency
|
||||
placeholders := make([]string, len(paths))
|
||||
args := make([]interface{}, len(paths)+1)
|
||||
args[0] = source
|
||||
for i, path := range paths {
|
||||
placeholders[i] = "?"
|
||||
args[i+1] = path
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT path, has_preview
|
||||
FROM index_items WHERE source = ? AND path IN (%s) AND is_dir = 1
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
// Soft failure: DB is busy or locked, return empty map
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
return make(map[string]bool), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var path string
|
||||
var hasPreview bool
|
||||
if err := rows.Scan(&path, &hasPreview); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[path] = hasPreview
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDirectoryChildren retrieves all children of a directory for a specific source.
|
||||
func (db *IndexDB) GetDirectoryChildren(source, dirPath string) ([]*iteminfo.FileInfo, error) {
|
||||
query := `
|
||||
@@ -1077,17 +1067,31 @@ func (db *IndexDB) UpdateFolderSizesIfChanged(source string, pathSizes map[strin
|
||||
args = append(args, path)
|
||||
}
|
||||
|
||||
result, err := db.Exec(query, args...)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
logger.Debugf("[DB_TX] UpdateFolderSizesIfChanged: DB busy/locked, skipping update")
|
||||
return 0, nil // Non-fatal: sizes will be synced on next scan
|
||||
startTime := time.Now()
|
||||
for attempt := 1; attempt <= dbTransientOpAttempts; attempt++ {
|
||||
result, err := db.Exec(query, args...)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
if attempt < dbTransientOpAttempts {
|
||||
logger.Warningf("[DB_TX] UpdateFolderSizesIfChanged: busy/locked (attempt %d/%d), retrying after %v: %v",
|
||||
attempt, dbTransientOpAttempts, dbTransientRetryBackoff, err)
|
||||
time.Sleep(dbTransientRetryBackoff)
|
||||
continue
|
||||
}
|
||||
logger.Warningf("[DB_TX] UpdateFolderSizesIfChanged: still busy after %d attempts (%d paths, %v) — skipped",
|
||||
dbTransientOpAttempts, len(pathSizes), time.Since(startTime))
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return 0, err
|
||||
n, _ := result.RowsAffected()
|
||||
if attempt > 1 {
|
||||
logger.Infof("[DB_TX] UpdateFolderSizesIfChanged: succeeded on retry (%d rows, attempt %d, %v)",
|
||||
n, attempt, time.Since(startTime))
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
return rowsAffected, nil
|
||||
return 0, nil // defensive: unreachable when dbTransientOpAttempts >= 1
|
||||
}
|
||||
|
||||
// LoadFolderSizes loads all folder sizes for a source from the database
|
||||
@@ -1320,6 +1324,7 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
|
||||
return nil, err
|
||||
}
|
||||
info.ModTime = time.Unix(modTime, 0)
|
||||
info.IsDir = isDir
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -74,11 +74,6 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
return err
|
||||
}
|
||||
|
||||
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
|
||||
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
|
||||
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
|
||||
return errors.ErrNoTotpConfigured
|
||||
}
|
||||
if user.LoginMethod == "" {
|
||||
user.LoginMethod = existingUser.LoginMethod
|
||||
}
|
||||
@@ -86,11 +81,21 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
|
||||
if user.LoginMethod == "" {
|
||||
user.LoginMethod = existingUser.LoginMethod
|
||||
}
|
||||
|
||||
if user.TOTPNonce == "" {
|
||||
user.TOTPNonce = existingUser.TOTPNonce
|
||||
}
|
||||
if user.TOTPSecret == "" {
|
||||
user.TOTPSecret = existingUser.TOTPSecret
|
||||
}
|
||||
if user.LoginMethod == users.LoginMethodPassword && enforcedOtp && !user.OtpEnabled {
|
||||
if slices.Contains(fields, "OtpEnabled") {
|
||||
return errors.ErrNoTotpConfigured
|
||||
}
|
||||
}
|
||||
if !slices.Contains(fields, "Password") {
|
||||
user.Password = existingUser.Password
|
||||
} else {
|
||||
@@ -113,10 +118,16 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, scope := range adjustedScopes {
|
||||
// ensure full path exists
|
||||
if scope.Scope == "" {
|
||||
return fmt.Errorf("scope name is required")
|
||||
}
|
||||
}
|
||||
user.Scopes = adjustedScopes
|
||||
err = files.MakeUserDirs(user, true)
|
||||
err = files.MakeUserDirs(user, false)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
// converting scopes to map of paths intead of names (names can change)
|
||||
@@ -186,7 +197,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
|
||||
return err
|
||||
}
|
||||
user.Scopes = adjustedScopes
|
||||
err = files.MakeUserDirs(user, disableScopeChange)
|
||||
err = files.MakeUserDirs(user, true)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
|
||||
@@ -83,7 +83,6 @@ type User struct {
|
||||
LoginMethod LoginMethod `json:"loginMethod"`
|
||||
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
|
||||
Version int `json:"version"`
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
// legacy for migration purposes... og filebrowser has perm attribute
|
||||
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
|
||||
}
|
||||
@@ -121,12 +120,14 @@ type NonAdminEditable struct {
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
|
||||
+37
-37
@@ -4,35 +4,35 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/gen2brain/go-fitz v1.24.15
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-logger v1.0.2
|
||||
github.com/kovidgoyal/imaging v1.8.20
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
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/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.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/mod v0.34.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.43.0
|
||||
golang.org/x/tools v0.44.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.47.0
|
||||
modernc.org/sqlite v1.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -50,18 +50,18 @@ require (
|
||||
github.com/Antonboom/errname v1.1.1 // indirect
|
||||
github.com/Antonboom/nilnil v1.1.1 // indirect
|
||||
github.com/Antonboom/testifylint v1.6.4 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/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/MirrexOne/unqueryvet v1.5.3 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.64.5 // indirect
|
||||
github.com/air-verse/air v1.65.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.2 // indirect
|
||||
github.com/alexkohler/prealloc v1.1.0 // indirect
|
||||
github.com/alfatraining/structtag v1.0.0 // indirect
|
||||
github.com/alingse/asasalint v0.0.11 // indirect
|
||||
github.com/alingse/nilnesserr v0.2.0 // indirect
|
||||
@@ -103,7 +103,7 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.6 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
@@ -113,17 +113,17 @@ require (
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-critic/go-critic v0.14.3 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||
github.com/go-openapi/spec v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/conv v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-toolsmith/astcast v1.1.0 // indirect
|
||||
@@ -139,13 +139,13 @@ require (
|
||||
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-20260302211937-87f5a40ea07a // indirect
|
||||
github.com/golang/geo v0.0.0-20260415063119-550b242b3150 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golangci/asciicheck v0.5.0 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.11.4 // indirect
|
||||
github.com/golangci/golines v0.15.0 // indirect
|
||||
github.com/golangci/misspell v0.8.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
@@ -171,7 +171,7 @@ require (
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/jupiterrider/ffi v0.6.0 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.9.0 // indirect
|
||||
github.com/kisielk/errcheck v1.10.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
@@ -196,10 +196,10 @@ require (
|
||||
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.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/mgechev/revive v1.14.0 // 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
|
||||
@@ -228,15 +228,15 @@ 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/ryanrolds/sqlclosecheck v0.5.1 // 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.23.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sivchari/containedctx v1.0.3 // indirect
|
||||
github.com/sonatard/noctx v0.4.0 // indirect
|
||||
github.com/sonatard/noctx v0.5.1 // indirect
|
||||
github.com/sourcegraph/go-diff v0.7.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
@@ -257,7 +257,7 @@ require (
|
||||
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.0 // indirect
|
||||
github.com/uudashr/gocognit v1.2.1 // indirect
|
||||
github.com/uudashr/iface v1.4.1 // indirect
|
||||
github.com/xen0n/gosmopolitan v1.3.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
@@ -276,12 +276,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.35.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/libc v1.72.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
|
||||
+86
-87
@@ -59,8 +59,8 @@ github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksuf
|
||||
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
|
||||
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
|
||||
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
@@ -75,14 +75,14 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
|
||||
github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
|
||||
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.65.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=
|
||||
@@ -100,8 +100,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
|
||||
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
|
||||
github.com/alexkohler/prealloc v1.0.2/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
|
||||
github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M=
|
||||
github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
|
||||
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
|
||||
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
|
||||
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
|
||||
@@ -203,8 +203,8 @@ github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyM
|
||||
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/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
@@ -258,8 +258,8 @@ github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
|
||||
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
|
||||
github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
|
||||
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
|
||||
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
|
||||
github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
|
||||
@@ -294,8 +294,8 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
@@ -306,41 +306,41 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
|
||||
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
|
||||
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
|
||||
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
|
||||
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
|
||||
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
|
||||
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
|
||||
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
|
||||
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
|
||||
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
@@ -401,8 +401,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
|
||||
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-20260302211937-87f5a40ea07a h1:7f/cr+n7Jtd7mzCLrx47sdVIGabpSSxCHrf6fJczDp0=
|
||||
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20260415063119-550b242b3150 h1:F5CQANtNulclG+xJt370ohUujAEHq2XNBBAjkoSukQI=
|
||||
github.com/golang/geo v0.0.0-20260415063119-550b242b3150/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@@ -442,8 +442,8 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
|
||||
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
|
||||
github.com/golangci/golangci-lint/v2 v2.11.4 h1:GK+UlZBN5y7rh2PBnHA93XLSX6RaF7uhzJQ3JwU1wuA=
|
||||
github.com/golangci/golangci-lint/v2 v2.11.4/go.mod h1:ODQDCASMA3VqfZYIbbQLpTRTzV7O/vjmIRF6u8NyFwI=
|
||||
github.com/golangci/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=
|
||||
@@ -506,8 +506,8 @@ github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+
|
||||
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
|
||||
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
|
||||
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
|
||||
github.com/gtsteffaniak/go-logger v1.0.2 h1:S4AgR7yDu2geD8y2D9NyJVOBqpZRnl37r2mBi7GDNR0=
|
||||
github.com/gtsteffaniak/go-logger v1.0.2/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
|
||||
@@ -569,8 +569,8 @@ github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBn
|
||||
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
|
||||
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
|
||||
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
|
||||
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
|
||||
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
|
||||
@@ -580,8 +580,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
|
||||
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
|
||||
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
|
||||
github.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=
|
||||
github.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=
|
||||
github.com/kovidgoyal/imaging v1.8.21 h1:95S2+dowTeKJJHNpf6lnScvIennTr2H0zQotu+ptNQw=
|
||||
github.com/kovidgoyal/imaging v1.8.21/go.mod h1:976F+zjiQeZ7sd87Pxlm0a64S/w9bImSIWg3sSk1rdQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -640,16 +640,16 @@ 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
|
||||
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
|
||||
github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
|
||||
github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -769,8 +769,8 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
|
||||
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
|
||||
github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU=
|
||||
github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ=
|
||||
github.com/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=
|
||||
github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
@@ -779,8 +779,8 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
|
||||
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
|
||||
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
|
||||
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
|
||||
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
|
||||
github.com/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=
|
||||
@@ -793,8 +793,8 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
|
||||
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
|
||||
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
|
||||
github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
|
||||
github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
|
||||
github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
|
||||
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
|
||||
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -860,8 +860,8 @@ github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSW
|
||||
github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8=
|
||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA=
|
||||
github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU=
|
||||
github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
|
||||
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
|
||||
github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
|
||||
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
|
||||
@@ -928,8 +928,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -948,8 +948,8 @@ golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjO
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -975,8 +975,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
||||
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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1019,8 +1019,8 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1094,12 +1094,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.6.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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -1116,8 +1115,8 @@ 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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1173,8 +1172,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
@@ -1291,10 +1290,10 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
|
||||
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
|
||||
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
@@ -1303,18 +1302,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
|
||||
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
+20
-4
@@ -147,6 +147,22 @@ type AuthTokenFrontend struct {
|
||||
Permissions users.Permissions `json:"Permissions,omitempty"`
|
||||
}
|
||||
|
||||
// authTokenIssuedUnix returns iat from JWT claims when set, otherwise the persisted int64 (e.g. loaded from DB).
|
||||
func authTokenIssuedUnix(t users.AuthToken) int64 {
|
||||
if t.RegisteredClaims.IssuedAt != nil {
|
||||
return t.RegisteredClaims.IssuedAt.Unix()
|
||||
}
|
||||
return t.IssuedAt
|
||||
}
|
||||
|
||||
// authTokenExpiresUnix returns exp from JWT claims when set, otherwise the persisted int64.
|
||||
func authTokenExpiresUnix(t users.AuthToken) int64 {
|
||||
if t.RegisteredClaims.ExpiresAt != nil {
|
||||
return t.RegisteredClaims.ExpiresAt.Unix()
|
||||
}
|
||||
return t.ExpiresAt
|
||||
}
|
||||
|
||||
// listApiTokensHandler lists all API tokens or retrieves details for a specific token.
|
||||
// @Summary List API tokens
|
||||
// @Description List all API tokens or retrieve details for a specific token.
|
||||
@@ -169,8 +185,8 @@ func listApiTokensHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
AuthTokensFrontend = append(AuthTokensFrontend, AuthTokenFrontend{
|
||||
Token: token.Token,
|
||||
Name: name,
|
||||
IssuedAt: token.RegisteredClaims.IssuedAt.Unix(),
|
||||
ExpiresAt: token.RegisteredClaims.ExpiresAt.Unix(),
|
||||
IssuedAt: authTokenIssuedUnix(token),
|
||||
ExpiresAt: authTokenExpiresUnix(token),
|
||||
Permissions: token.Permissions,
|
||||
})
|
||||
}
|
||||
@@ -205,8 +221,8 @@ func getApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
AuthTokenFrontendResponse := AuthTokenFrontend{
|
||||
Token: tokenInfo.Token,
|
||||
Name: name,
|
||||
IssuedAt: tokenInfo.IssuedAt,
|
||||
ExpiresAt: tokenInfo.ExpiresAt,
|
||||
IssuedAt: authTokenIssuedUnix(tokenInfo),
|
||||
ExpiresAt: authTokenExpiresUnix(tokenInfo),
|
||||
Permissions: tokenInfo.Permissions,
|
||||
}
|
||||
return renderJSON(w, r, AuthTokenFrontendResponse)
|
||||
|
||||
+128
-50
@@ -11,7 +11,9 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -103,34 +105,34 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullDest := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDest, d.user.Username) {
|
||||
fullDestFile := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestFile, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
// Destination is a file we are about to create; resolve parent dir only (parent must exist or be creatable).
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
fullDestParent := utils.GetParentDirectoryPath(fullDestFile)
|
||||
if store.Access != nil && fullDestParent != fullDestFile && !store.Access.Permitted(idxTo.Path, fullDestParent, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination parent of %s", req.Destination)
|
||||
}
|
||||
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
|
||||
}
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
parentReal, _, err := idxTo.GetRealPath(fullDestParent)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %v", err)
|
||||
}
|
||||
destRealPath := filepath.Join(destParentReal, filepath.Base(req.Destination))
|
||||
if err = os.MkdirAll(destParentReal, fileutils.PermDir); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
|
||||
if err = os.MkdirAll(parentReal, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %v", err)
|
||||
}
|
||||
destRel := strings.TrimLeft(filepath.ToSlash(req.Destination), "/")
|
||||
fileName := path.Base(destRel)
|
||||
if fileName == "." || fileName == "/" {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination file name")
|
||||
}
|
||||
destFileReal := filepath.Join(parentReal, fileName)
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(req.Format))
|
||||
if format == "" {
|
||||
ext := strings.ToLower(filepath.Ext(destRealPath))
|
||||
if ext == ".gz" && len(destRealPath) > 3 && strings.HasSuffix(strings.ToLower(destRealPath), ".tar.gz") {
|
||||
destLower := strings.ToLower(req.Destination)
|
||||
if strings.HasSuffix(destLower, ".tar.gz") {
|
||||
format = "tar.gz"
|
||||
} else if ext == ".zip" {
|
||||
} else if strings.HasSuffix(destLower, ".zip") || filepath.Ext(req.Destination) == ".zip" {
|
||||
format = "zip"
|
||||
} else {
|
||||
format = "zip"
|
||||
@@ -172,7 +174,7 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
}
|
||||
|
||||
var createErr error
|
||||
file, err := os.OpenFile(destRealPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
file, err := os.OpenFile(destFileReal, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.EffectiveFilePerm())
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -299,22 +301,19 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestPath, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
// Destination may not exist yet; resolve parent dir then build destination path.
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
}
|
||||
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
|
||||
}
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
destReal, _, err := idxTo.GetRealPath(fullDestPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
|
||||
}
|
||||
destReal := filepath.Join(destParentReal, filepath.Base(req.Destination))
|
||||
if err = os.MkdirAll(destReal, fileutils.PermDir); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
|
||||
destInfo, err := os.Stat(destReal)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory does not exist: %s", req.Destination)
|
||||
}
|
||||
return http.StatusInternalServerError, fmt.Errorf("destination: %v", err)
|
||||
}
|
||||
if !destInfo.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination must be a directory: %s", req.Destination)
|
||||
}
|
||||
|
||||
info, err := os.Stat(archiveReal)
|
||||
@@ -649,13 +648,53 @@ type unarchiveRequest struct {
|
||||
DeleteAfter bool `json:"deleteAfter"`
|
||||
}
|
||||
|
||||
// safeExtractPath ensures name does not escape destDir (no ".." or absolute).
|
||||
func safeExtractPath(destDir, name string) (string, error) {
|
||||
clean := filepath.Clean(name)
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
// normalizeArchiveEntryName turns a raw zip/tar name into a safe relative path (slash-separated).
|
||||
// Many Windows zips use backslashes; some start with a leading backslash, which would make
|
||||
// filepath.Join drop destDir and write under the drive root. Backslashes are not path separators
|
||||
// on Unix, so we always normalize '\' -> '/' and trim leading '/' before path.Clean.
|
||||
func normalizeArchiveEntryName(name string) (string, error) {
|
||||
s := strings.TrimSpace(name)
|
||||
if s == "" {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
abs := filepath.Join(destDir, clean)
|
||||
s = strings.ReplaceAll(s, "\\", "/")
|
||||
if strings.HasPrefix(s, "//") {
|
||||
return "", errors.New("UNC or invalid path")
|
||||
}
|
||||
s = strings.TrimLeft(s, "/")
|
||||
// Reject e.g. "C:/path" in archive
|
||||
if len(s) >= 3 && s[1] == ':' && s[2] == '/' {
|
||||
if 'A' <= s[0] && s[0] <= 'Z' || 'a' <= s[0] && s[0] <= 'z' {
|
||||
return "", errors.New("absolute path in archive")
|
||||
}
|
||||
}
|
||||
if s == ".." {
|
||||
return "", errors.New("path traversal")
|
||||
}
|
||||
if strings.HasPrefix(s, "../") {
|
||||
return "", errors.New("path traversal")
|
||||
}
|
||||
cleaned := path.Clean(s)
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return "", errors.New("empty path after clean")
|
||||
}
|
||||
if cleaned == ".." {
|
||||
return "", errors.New("path traversal after clean")
|
||||
}
|
||||
if strings.HasPrefix(cleaned, "../") {
|
||||
return "", errors.New("path traversal after clean")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// safeExtractPath ensures name does not escape destDir (no ".." or drive-root / UNC tricks).
|
||||
func safeExtractPath(destDir, name string) (string, error) {
|
||||
relSlash, err := normalizeArchiveEntryName(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid entry path: %q: %w", name, err)
|
||||
}
|
||||
// Convert to platform separators so filepath.Join and os.Mkdir work correctly
|
||||
abs := filepath.Join(destDir, filepath.FromSlash(relSlash))
|
||||
destAbs, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -664,8 +703,13 @@ func safeExtractPath(destDir, name string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(entryAbs, destAbs) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
// Case-sensitive/prefix issues on Windows: use Rel instead of HasPrefix on absolute paths
|
||||
rel, err := filepath.Rel(destAbs, entryAbs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid entry path: %q: %w", name, err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid entry path: %q", name)
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
@@ -730,10 +774,10 @@ func symlinkTargetStaysUnderDest(destRoot, destPath, linkname string) error {
|
||||
func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) error {
|
||||
perm := mode.Perm()
|
||||
if perm == 0 {
|
||||
perm = fileutils.PermDir
|
||||
perm = fileutils.EffectiveDirPerm()
|
||||
}
|
||||
if err := os.MkdirAll(destPath, perm); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("mkdir %q: %w", destPath, err)
|
||||
}
|
||||
return applyArchivedTimesAndPerm(destPath, mode, modTime)
|
||||
}
|
||||
@@ -741,12 +785,13 @@ func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) er
|
||||
// extractArchivedRegularFile writes one regular file from src (closed by this function) and reapplies mode and mtime.
|
||||
func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.Time, src io.ReadCloser) error {
|
||||
defer func() { _ = src.Close() }()
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
parent := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(parent, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return fmt.Errorf("mkdir %q: %w", parent, err)
|
||||
}
|
||||
perm := mode.Perm()
|
||||
if perm == 0 {
|
||||
perm = fileutils.PermFile
|
||||
perm = fileutils.EffectiveFilePerm()
|
||||
}
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
@@ -766,8 +811,9 @@ func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.
|
||||
// extractArchivedSymlink creates a symlink after validating the target stays under destRoot.
|
||||
func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time) error {
|
||||
target = strings.TrimSpace(target)
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
symParent := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(symParent, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return fmt.Errorf("mkdir %q: %w", symParent, err)
|
||||
}
|
||||
if err := symlinkTargetStaysUnderDest(destRoot, destPath, target); err != nil {
|
||||
return err
|
||||
@@ -788,7 +834,39 @@ func extractZip(archivePath, destDir string) error {
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Central directory order is arbitrary. Some zips (e.g. Windows driver packs) use names that
|
||||
// are similar length at different tree depths, so byte length is a poor order key. Use the
|
||||
// number of path segments in the *normalized* entry name: deeper trees first, then
|
||||
// lexicographic, so parent dirs exist before shorter entries that might be mis-tagged.
|
||||
type orderedName struct {
|
||||
f *zip.File
|
||||
rel string
|
||||
depth int
|
||||
}
|
||||
ordered := make([]orderedName, 0, len(r.File))
|
||||
for _, f := range r.File {
|
||||
rel, nerr := normalizeArchiveEntryName(f.Name)
|
||||
if nerr != nil {
|
||||
rel = strings.TrimLeft(strings.ReplaceAll(strings.TrimSpace(f.Name), "\\", "/"), "/")
|
||||
}
|
||||
depth := 0
|
||||
if rel != "" {
|
||||
depth = strings.Count(rel, "/") + 1
|
||||
}
|
||||
ordered = append(ordered, orderedName{f: f, rel: rel, depth: depth})
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
if ordered[i].depth != ordered[j].depth {
|
||||
return ordered[i].depth > ordered[j].depth
|
||||
}
|
||||
if ordered[i].rel != ordered[j].rel {
|
||||
return ordered[i].rel < ordered[j].rel
|
||||
}
|
||||
return ordered[i].f.Name < ordered[j].f.Name
|
||||
})
|
||||
|
||||
for _, o := range ordered {
|
||||
f := o.f
|
||||
var destPath string
|
||||
destPath, err = safeExtractPath(destDir, f.Name)
|
||||
if err != nil {
|
||||
@@ -797,8 +875,8 @@ func extractZip(archivePath, destDir string) error {
|
||||
|
||||
fi := f.FileInfo()
|
||||
mode := fi.Mode()
|
||||
|
||||
if mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") {
|
||||
isDirEntry := mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") || strings.HasSuffix(f.Name, "\\")
|
||||
if isDirEntry {
|
||||
if err = extractArchivedDir(destPath, mode, f.Modified); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeArchiveEntryName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "simple forward slashes",
|
||||
input: "DOCS/guide.pdf",
|
||||
want: "DOCS/guide.pdf",
|
||||
},
|
||||
{
|
||||
name: "Windows backslashes become logical path",
|
||||
input: `DOCS\QUICK\chs.gif`,
|
||||
want: "DOCS/QUICK/chs.gif",
|
||||
},
|
||||
{
|
||||
name: "Intel-style leading backslash stripped so path stays relative",
|
||||
input: `\PRO1000\Winx64\NDIS65\e1c65x64.cat`,
|
||||
want: "PRO1000/Winx64/NDIS65/e1c65x64.cat",
|
||||
},
|
||||
{
|
||||
name: "leading forward slashes stripped",
|
||||
input: "/PRO1000/Winx64/readme.txt",
|
||||
want: "PRO1000/Winx64/readme.txt",
|
||||
},
|
||||
{
|
||||
name: "directory entry with trailing slash",
|
||||
input: `PRO1000\Winx64\`,
|
||||
want: "PRO1000/Winx64",
|
||||
},
|
||||
{
|
||||
name: "mixed redundant segments cleaned",
|
||||
input: "a/./b/../c/file",
|
||||
want: "a/c/file",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantErr: "empty path",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " ",
|
||||
wantErr: "empty path",
|
||||
},
|
||||
{
|
||||
name: "UNC rejected",
|
||||
input: `//server/share/file`,
|
||||
wantErr: "UNC or invalid path",
|
||||
},
|
||||
{
|
||||
name: "double slash prefix after backslash replace (UNC-style)",
|
||||
input: `\\?\C:\temp\file`,
|
||||
wantErr: "UNC or invalid path",
|
||||
},
|
||||
{
|
||||
name: "Windows drive absolute rejected",
|
||||
input: `C:/Windows/system32`,
|
||||
wantErr: "absolute path in archive",
|
||||
},
|
||||
{
|
||||
name: "lowercase drive rejected",
|
||||
input: `c:/data/file.txt`,
|
||||
wantErr: "absolute path in archive",
|
||||
},
|
||||
{
|
||||
name: "bare dot-dot",
|
||||
input: "..",
|
||||
wantErr: "path traversal",
|
||||
},
|
||||
{
|
||||
name: "prefixed dot-dot",
|
||||
input: "../etc/passwd",
|
||||
wantErr: "path traversal",
|
||||
},
|
||||
{
|
||||
name: "traversal after path clean",
|
||||
input: "safe/../../outside",
|
||||
wantErr: "path traversal after clean",
|
||||
},
|
||||
{
|
||||
name: "only dots and slashes",
|
||||
input: "./",
|
||||
wantErr: "empty path after clean",
|
||||
},
|
||||
{
|
||||
name: "path cleans to nothing",
|
||||
input: "x/../.",
|
||||
wantErr: "empty path after clean",
|
||||
},
|
||||
{
|
||||
name: "spaces preserved in file name",
|
||||
input: "My Folder/read me.txt",
|
||||
want: "My Folder/read me.txt",
|
||||
},
|
||||
{
|
||||
name: "deep relative tree",
|
||||
input: "a/b/c/d/e/f/g.bin",
|
||||
want: "a/b/c/d/e/f/g.bin",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeArchiveEntryName(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil (result %q)", tt.wantErr, got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("normalizeArchiveEntryName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeArchiveEntryName_backslashInFileNameOnUnix documents that after normalization
|
||||
// a single backslash in the raw name is never a path separator: it becomes a slash in the
|
||||
// logical path (portable), so segments split correctly on every GOOS in safeExtractPath.
|
||||
func TestNormalizeArchiveEntryName_unifiesBackslashes(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeArchiveEntryName(`folder\file.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "folder/file.txt"; got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeExtractPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
dest := t.TempDir()
|
||||
dest, err := filepath.Abs(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("file under dest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := safeExtractPath(dest, `DOCS\Adapter_User_Guide.pdf`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(dest, "DOCS", "Adapter_User_Guide.pdf")
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leading backslash in zip name stays under dest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := safeExtractPath(dest, `\sub\file.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(got, dest+string(filepath.Separator)) {
|
||||
t.Fatalf("result %q should be under dest %q", got, dest)
|
||||
}
|
||||
rel, err := filepath.Rel(dest, got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel != filepath.Join("sub", "file.txt") {
|
||||
t.Fatalf("Rel = %q, want sub%cfile.txt", rel, filepath.Separator)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects path that escapes dest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := safeExtractPath(dest, "../../../x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for parent segments in archive name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid entry path") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects when normalize fails", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := safeExtractPath(dest, "..")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid entry path") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSafeExtractPath_rejectsParentTraversal ensures ".." in the archive name is rejected
|
||||
// by normalizeArchiveEntryName, not written under dest.
|
||||
func TestSafeExtractPath_rejectsParentTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
dest := t.TempDir()
|
||||
_, err := safeExtractPath(dest, `pkg/../../../secret`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path traversal in archive name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeExtractPath_destJoinDoesNotDropBase verifies that a previously problematic pattern
|
||||
// (root-relative second segment) does not throw away dest after normalization.
|
||||
func TestSafeExtractPath_destJoinDoesNotDropBase(t *testing.T) {
|
||||
t.Parallel()
|
||||
dest := t.TempDir()
|
||||
out, err := safeExtractPath(dest, `a\b\c.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(out, dest+string(filepath.Separator)) {
|
||||
t.Fatalf("expected path under %q, got %q", dest, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractZip_nestedBackslashes extracts a Windows-style path in a zip (backslashes) into
|
||||
// nested directories under dest. This matches many vendor driver zips.
|
||||
func TestExtractZip_nestedBackslashes(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmp := t.TempDir()
|
||||
zipPath := filepath.Join(tmp, "test.zip")
|
||||
out := filepath.Join(tmp, "out")
|
||||
if err := os.MkdirAll(out, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
w, err := zw.Create(`DOCS\QUICK\readme.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = w.Write([]byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = extractZip(zipPath, out); err != nil {
|
||||
t.Fatalf("extractZip: %v", err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(out, "DOCS", "QUICK", "readme.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != "hello" {
|
||||
t.Fatalf("content = %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmarks (optional, keep small)
|
||||
func BenchmarkNormalizeArchiveEntryName(b *testing.B) {
|
||||
const name = `PRO1000\Winx64\NDIS65\e1c65x64.cat`
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = normalizeArchiveEntryName(name)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
dispositionType := "attachment"
|
||||
if r.URL.Query().Get("inline") == "true" {
|
||||
dispositionType = "inline"
|
||||
// Inline SVG (and similar) can execute embedded scripts when opened as a top-level document; match upstream filebrowser mitigation.
|
||||
w.Header().Set("Content-Security-Policy", "script-src 'none'")
|
||||
}
|
||||
|
||||
// standard: ASCII-only safe fallback
|
||||
|
||||
@@ -219,6 +219,7 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
|
||||
}
|
||||
if isText {
|
||||
response.IsText = true
|
||||
// Read the last N lines for text files only
|
||||
content, err := readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
|
||||
@@ -92,11 +92,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// ========================================
|
||||
// Auth Routes - /api/auth/
|
||||
// ========================================
|
||||
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
|
||||
api.HandleFunc("POST /auth/login", loginHelper(loginHandler))
|
||||
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
|
||||
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
|
||||
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(generateOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(verifyOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/generate", withOrWithoutUser(generateOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/verify", withOrWithoutUser(verifyOTPHandler))
|
||||
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
|
||||
api.HandleFunc("POST /auth/token", withUser(createApiTokenHandler))
|
||||
api.HandleFunc("DELETE /auth/token", withUser(deleteApiTokenHandler))
|
||||
@@ -150,7 +150,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// ========================================
|
||||
api.HandleFunc("GET /share/list", withPermShare(shareListHandler))
|
||||
api.HandleFunc("GET /share/direct", withPermShare(shareDirectDownloadHandler))
|
||||
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
|
||||
api.HandleFunc("GET /share", withUser(shareGetHandler))
|
||||
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
|
||||
api.HandleFunc("PATCH /share", withPermShare(sharePatchHandler))
|
||||
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
|
||||
|
||||
@@ -109,8 +109,9 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the directory"
|
||||
// @Param path query string true "Path to the directory or file"
|
||||
// @Param source query string true "Source name"
|
||||
// @Param albumArt query bool false "When true, include embedded album art bytes in audio metadata"
|
||||
// @Success 200 {object} iteminfo.ExtendedFileInfo
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
@@ -118,6 +119,7 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
func metadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
albumArt := r.URL.Query().Get("albumArt") == "true"
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: path,
|
||||
@@ -125,6 +127,7 @@ func metadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
Expand: true,
|
||||
Content: false,
|
||||
Metadata: true,
|
||||
AlbumArt: albumArt,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
SkipExtendedAttrs: false,
|
||||
@@ -142,6 +145,7 @@ func metadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash"
|
||||
// @Param path query string false "Path within the share"
|
||||
// @Param albumArt query bool false "When true, include embedded album art bytes in audio metadata (heavier)"
|
||||
// @Success 200 {object} iteminfo.ExtendedFileInfo
|
||||
// @Router /public/api/media/metadata [get]
|
||||
func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -149,6 +153,7 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
albumArt := r.URL.Query().Get("albumArt") == "true"
|
||||
sourceCfg, ok := config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return http.StatusNotFound, fmt.Errorf("source not found")
|
||||
@@ -159,6 +164,7 @@ func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
Expand: true,
|
||||
Content: false,
|
||||
Metadata: true,
|
||||
AlbumArt: albumArt,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.share.ShowHidden,
|
||||
FollowSymlinks: false,
|
||||
|
||||
@@ -53,7 +53,11 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
|
||||
func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
path := r.URL.Query().Get("path")
|
||||
inputPath := r.URL.Query().Get("path")
|
||||
path, err := utils.SanitizeUserPath(inputPath)
|
||||
if err != nil && inputPath != "" {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
// Get the file link by hash
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
@@ -314,7 +318,7 @@ func withoutUserHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
|
||||
// allow user without OTP to pass
|
||||
func userWithoutOTPhelper(fn handleFunc) handleFunc {
|
||||
func LoginHelper(disableOtp bool, fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
|
||||
if config.Auth.Methods.ProxyAuth.Enabled {
|
||||
@@ -344,15 +348,19 @@ func userWithoutOTPhelper(fn handleFunc) handleFunc {
|
||||
logger.Debug("ldap auth failed, calling password auth", err)
|
||||
}
|
||||
if config.Auth.Methods.PasswordAuth.Enabled {
|
||||
// Get the authentication method from the settings
|
||||
auther, err := store.Auth.Get("password")
|
||||
if err != nil {
|
||||
return 401, errors.ErrUnauthorized
|
||||
}
|
||||
// Authenticate the user based on the request
|
||||
// OTP routes: password only (do not mutate stored *JSONAuth).
|
||||
if ja, ok := auther.(*auth.JSONAuth); ok {
|
||||
x := *ja
|
||||
x.DisableOtp = disableOtp
|
||||
auther = &x
|
||||
}
|
||||
user, err := auther.Auth(r, store.Users)
|
||||
if err != nil {
|
||||
logger.Debug("password auth failed, calling handler", err)
|
||||
logger.Debug("password auth failed, calling handler:", err)
|
||||
if err == errors.ErrNoTotpProvided {
|
||||
return 403, err
|
||||
}
|
||||
@@ -655,8 +663,8 @@ func withoutUser(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(withoutUserHelper(fn))
|
||||
}
|
||||
|
||||
func userWithoutOTP(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(userWithoutOTPhelper(fn))
|
||||
func loginHelper(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(LoginHelper(false, fn))
|
||||
}
|
||||
|
||||
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
|
||||
|
||||
+101
-24
@@ -42,34 +42,112 @@ func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract groups if configured
|
||||
// Extract groups if configured (supports nested claims like "resource_access.filebrowser-client.roles")
|
||||
if u.groupsClaim != "" {
|
||||
if v, ok := raw[u.groupsClaim]; ok {
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
u.userInfo.Groups = slices.Collect(maps.Keys(val))
|
||||
case []interface{}:
|
||||
groups := make([]string, len(val))
|
||||
for i, g := range val {
|
||||
if s, ok := g.(string); ok {
|
||||
groups[i] = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
u.userInfo.Groups = groups
|
||||
case string:
|
||||
if val != "" {
|
||||
parts := strings.Split(val, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
u.userInfo.Groups = parts
|
||||
u.userInfo.Groups = extractGroupsFromOIDCClaims(raw, u.groupsClaim)
|
||||
}
|
||||
|
||||
u.userInfo.Claims = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractGroupsFromOIDCClaims extracts groups from OIDC claims supporting nested paths.
|
||||
// Supports both direct claims (e.g., "groups") and nested claims using '.' separator (e.g., "resource_access.filebrowser-client.roles")
|
||||
func extractGroupsFromOIDCClaims(claims map[string]interface{}, groupsClaimField string) []string {
|
||||
var groups []string
|
||||
|
||||
if groupsClaimField == "" {
|
||||
return groups
|
||||
}
|
||||
|
||||
// Try to resolve nested path first (e.g., "custom.groups")
|
||||
groupsVal := resolveOIDCNestedClaim(claims, groupsClaimField)
|
||||
|
||||
if groupsVal == nil {
|
||||
// If nested path didn't work, try direct lookup
|
||||
if val, ok := claims[groupsClaimField]; ok {
|
||||
groupsVal = val
|
||||
}
|
||||
}
|
||||
|
||||
if groupsVal != nil {
|
||||
groups = parseOIDCGroupsValue(groupsVal)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// resolveOIDCNestedClaim resolves nested OIDC claims using '.' as a separator
|
||||
func resolveOIDCNestedClaim(claims map[string]interface{}, path string) interface{} {
|
||||
parts := strings.Split(path, ".")
|
||||
current := interface{}(claims)
|
||||
|
||||
for _, part := range parts {
|
||||
switch v := current.(type) {
|
||||
case map[string]interface{}:
|
||||
if val, ok := v[part]; ok {
|
||||
current = val
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
// parseOIDCGroupsValue parses OIDC claim values into a string slice of groups
|
||||
// Returns nil for missing/empty non-array values, []string{} for empty arrays
|
||||
func parseOIDCGroupsValue(groupsVal interface{}) []string {
|
||||
var groups []string
|
||||
|
||||
switch val := groupsVal.(type) {
|
||||
case map[string]interface{}:
|
||||
// Groups as map keys - return empty slice for empty map
|
||||
groups = slices.Collect(maps.Keys(val))
|
||||
case []interface{}:
|
||||
// Groups as array of interfaces - always initialize as empty slice for arrays
|
||||
groups = []string{}
|
||||
for _, g := range val {
|
||||
if groupStr, ok := g.(string); ok {
|
||||
groups = append(groups, strings.TrimSpace(groupStr))
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
// Groups as array of strings - always initialize as empty slice for arrays
|
||||
groups = val
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
case string:
|
||||
// Single group as string or comma-separated groups
|
||||
// Return nil for empty strings, parsed array otherwise
|
||||
if val != "" {
|
||||
parts := strings.Split(val, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
groups = parts
|
||||
}
|
||||
// else: groups stays nil for empty string
|
||||
default:
|
||||
// Try to unmarshal as JSON array
|
||||
if jsonBytes, err := json.Marshal(val); err == nil {
|
||||
var groupsArray []string
|
||||
if err := json.Unmarshal(jsonBytes, &groupsArray); err == nil {
|
||||
// Initialize as empty slice if array was parsed
|
||||
if groupsArray == nil {
|
||||
groups = []string{}
|
||||
} else {
|
||||
groups = groupsArray
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.userInfo.Claims = raw
|
||||
return nil
|
||||
return groups
|
||||
}
|
||||
|
||||
// oidcLoginHandler initiates OIDC login.
|
||||
@@ -177,7 +255,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// --- Attempt to process ID Token ---
|
||||
if ok && rawIDToken != "" {
|
||||
logger.Debug("ID token found in token response, attempting verification.")
|
||||
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
|
||||
@@ -202,7 +280,6 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
claimsFromIDToken = true
|
||||
}
|
||||
}
|
||||
logger.Debugf("Failed to verify ID token: %v", verify_err)
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
@@ -114,6 +114,64 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested single-level claim",
|
||||
jsonData: `{"username":"john","custom":{"groups":["admin","users"]}}`,
|
||||
groupsClaim: "custom.groups",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"username": "john",
|
||||
"custom": map[string]interface{}{
|
||||
"groups": []interface{}{"admin","users"},
|
||||
},
|
||||
},
|
||||
Groups: []string{"admin", "users"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak resource_access nested structure",
|
||||
jsonData: `{"sub":"user123","resource_access":{"filebrowser-client":{"roles":["admin","editor","viewer"]}}}`,
|
||||
groupsClaim: "resource_access.filebrowser-client.roles",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"resource_access": map[string]interface{}{
|
||||
"filebrowser-client": map[string]interface{}{
|
||||
"roles": []interface{}{"admin","editor","viewer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Groups: []string{"admin", "editor", "viewer"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested claim with single string value",
|
||||
jsonData: `{"username":"bob","auth":{"group":"admin"}}`,
|
||||
groupsClaim: "auth.group",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"username": "bob",
|
||||
"auth": map[string]interface{}{
|
||||
"group": "admin",
|
||||
},
|
||||
},
|
||||
Groups: []string{"admin"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid nested path returns empty groups",
|
||||
jsonData: `{"username":"alice","custom":{"roles":["user"]}}`,
|
||||
groupsClaim: "custom.nonexistent.roles",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"username": "alice",
|
||||
"custom": map[string]interface{}{
|
||||
"roles": []interface{}{"user"},
|
||||
},
|
||||
},
|
||||
Groups: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -105,7 +105,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
|
||||
// Build list of previewable item names in stable order (same as d.fileInfo.Files)
|
||||
var previewableNames []string
|
||||
for _, item := range d.fileInfo.Files {
|
||||
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
if !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
continue
|
||||
}
|
||||
previewableNames = append(previewableNames, item.Name)
|
||||
|
||||
+27
-13
@@ -251,25 +251,35 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
|
||||
// Process each item one at a time
|
||||
for _, item := range items {
|
||||
rawPath := item.Path
|
||||
// Validate item
|
||||
if item.Path == "" {
|
||||
if rawPath == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: rawPath,
|
||||
Message: "path was empty",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Prevent deletion of root
|
||||
if item.Path == "/" {
|
||||
if rawPath == "/" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: rawPath,
|
||||
Message: "cannot delete root directory",
|
||||
})
|
||||
continue
|
||||
}
|
||||
sanitizedPath, err := utils.SanitizeUserPath(rawPath)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if d.share != nil {
|
||||
sourceName := d.share.GetSourceName()
|
||||
@@ -281,13 +291,13 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "user does not have access",
|
||||
})
|
||||
continue
|
||||
}
|
||||
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
|
||||
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
|
||||
indexPath := utils.JoinPathAsUnix(withoutUserScope, sanitizedPath)
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
@@ -305,7 +315,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "error deleting file/directory, admin must check the logs",
|
||||
})
|
||||
continue
|
||||
@@ -317,7 +327,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if item.Source == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "source was empty, source is required",
|
||||
})
|
||||
continue
|
||||
@@ -328,7 +338,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: fmt.Sprintf("user does not have access: %v", err),
|
||||
})
|
||||
continue
|
||||
@@ -338,7 +348,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if idx == nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: "source not found",
|
||||
})
|
||||
continue
|
||||
@@ -347,14 +357,14 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// Get file info
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Source: item.Source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, filePermUser, store.Share)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
@@ -363,7 +373,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Path: sanitizedPath,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
@@ -521,6 +531,10 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
existingIsDir := stat.IsDir()
|
||||
requestingDir := isDir
|
||||
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
|
||||
// If type mismatch (file vs folder or folder vs file) and not overriding
|
||||
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("Type conflict detected in chunked: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
|
||||
|
||||
+13
-34
@@ -83,7 +83,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
staticURL := config.Server.BaseURL + "public/static"
|
||||
description := config.Frontend.Description
|
||||
title := config.Frontend.Name
|
||||
banner := staticURL + "/pwa-icon-512.png" // Use largest generated icon for best quality
|
||||
banner := staticURL + "/icons/pwa-icon-512.png" // largest generated PWA icon
|
||||
disableSidebar := false
|
||||
|
||||
// Use custom favicon if configured and validated, otherwise fall back to default
|
||||
@@ -139,9 +139,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
|
||||
// Determine OpenGraph image: use banner if set, otherwise use largest available icon (512x512)
|
||||
ogImage := banner
|
||||
if banner == staticURL+"/pwa-icon-512.png" {
|
||||
if banner == staticURL+"/icons/pwa-icon-512.png" {
|
||||
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) but square works fine
|
||||
ogImage = staticURL + "/pwa-icon-512.png"
|
||||
ogImage = staticURL + "/icons/pwa-icon-512.png"
|
||||
}
|
||||
|
||||
// Construct the full URL for the current request
|
||||
@@ -158,22 +158,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
fullURL = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path)
|
||||
}
|
||||
|
||||
// Determine PWA icon URLs based on custom favicon settings
|
||||
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
|
||||
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
|
||||
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
|
||||
|
||||
// If custom favicon is set and it's SVG, use the favicon directly
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
|
||||
pwaIcon192 = favicon
|
||||
pwaIcon256 = favicon
|
||||
pwaIcon512 = favicon
|
||||
} else if settings.Env.FaviconIsCustom {
|
||||
// For custom PNG/ICO favicons, use generated PWA icons
|
||||
pwaIcon192 = staticURL + "/pwa-icon-192.png"
|
||||
pwaIcon256 = staticURL + "/pwa-icon-256.png"
|
||||
pwaIcon512 = staticURL + "/pwa-icon-512.png"
|
||||
}
|
||||
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
|
||||
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
|
||||
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
|
||||
|
||||
data["htmlVars"] = map[string]interface{}{
|
||||
"title": title,
|
||||
@@ -186,8 +173,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"favicon": favicon,
|
||||
"loginIcon": loginIcon,
|
||||
"color": defaultThemeColor,
|
||||
"winIcon": staticURL + "/mstile-256x256.png",
|
||||
"appIcon": staticURL + "/apple-touch-icon.png",
|
||||
"winIcon": staticURL + "/icons/mstile-256x256.png",
|
||||
"appIcon": staticURL + "/icons/apple-touch-icon.png",
|
||||
"description": description,
|
||||
"loadingSpinnersCSS": template.CSS(loadingSpinnersCSS),
|
||||
"banner": banner,
|
||||
@@ -313,7 +300,7 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
assetPath = settings.Env.FaviconEmbeddedPath
|
||||
case "favicon.png":
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
|
||||
iconPath := filepath.Join(settings.PWAIconsCacheDir(), "pwa-icon-512.png")
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
@@ -323,18 +310,10 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "icons/favicon-32x32.png",
|
||||
"icons/pwa-icon-192.png", "icons/pwa-icon-256.png", "icons/pwa-icon-512.png",
|
||||
"icons/apple-touch-icon.png":
|
||||
// Serve generated icons from cache directory
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
}
|
||||
// Fall back to embedded favicon.png if generation failed
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "icons/mstile-256x256.png":
|
||||
// Windows tile - redirect to pwa-icon-256.png (they're identical)
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.png")
|
||||
"icons/apple-touch-icon.png", "icons/mstile-256x256.png":
|
||||
// Files are generated as PWAIconsCacheDir()/basename (URL uses icons/ prefix only for routing)
|
||||
rel := strings.TrimPrefix(path, "icons/")
|
||||
iconPath := filepath.Join(settings.PWAIconsCacheDir(), rel)
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
|
||||
+46
-25
@@ -3,9 +3,10 @@ package http
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
)
|
||||
|
||||
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
|
||||
@@ -21,18 +22,15 @@ import (
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/otp/generate [post]
|
||||
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
username = d.user.Username
|
||||
}
|
||||
if username != d.user.Username && !d.user.Permissions.Admin {
|
||||
return http.StatusForbidden, fmt.Errorf("you are not authorized to generate OTP for this user")
|
||||
}
|
||||
targetUser, err := store.Users.Get(username)
|
||||
err := checkPassword(r, d)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
url, err := auth.GenerateOtpForUser(targetUser, store.Users)
|
||||
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
|
||||
if getErr != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
|
||||
}
|
||||
url, err := auth.GenerateOtpForUser(user, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
|
||||
}
|
||||
@@ -61,21 +59,15 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if code == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("code is required")
|
||||
}
|
||||
|
||||
targetUser := d.user
|
||||
username := r.URL.Query().Get("username")
|
||||
|
||||
// If admin is verifying for another user
|
||||
if username != "" && username != d.user.Username && d.user.Permissions.Admin {
|
||||
var err error
|
||||
targetUser, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
logger.Debugf("Admin %s verifying OTP for user: %s", d.user.Username, targetUser.Username)
|
||||
err := checkPassword(r, d)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
|
||||
err := auth.VerifyTotpCode(targetUser, code, store.Users)
|
||||
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
|
||||
if getErr != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
|
||||
}
|
||||
err = auth.VerifyTotpCode(user, code, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
@@ -86,3 +78,32 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// On success, return a simple confirmation.
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
func checkPassword(r *http.Request, d *requestContext) error {
|
||||
providedPassword := r.Header.Get("X-Password")
|
||||
username := r.URL.Query().Get("username")
|
||||
if d.user.Permissions.Admin {
|
||||
username = d.user.Username
|
||||
}
|
||||
// url decode
|
||||
providedPassword, err := url.QueryUnescape(providedPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid password encoding: %v", err)
|
||||
}
|
||||
if providedPassword == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
user, getErr := store.Users.Get(username)
|
||||
var passwordHash string
|
||||
if getErr != nil {
|
||||
passwordHash = utils.InvalidPasswordHash
|
||||
} else {
|
||||
passwordHash = user.Password
|
||||
}
|
||||
// always run checkPwd to prevent timing attacks
|
||||
err = utils.CheckPwd(providedPassword, passwordHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid password or user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+90
-4
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -177,12 +180,14 @@ func normalizeLocale(locale string) string {
|
||||
|
||||
// userDeleteHandler deletes a user by ID.
|
||||
// @Summary Delete a user by ID
|
||||
// @Description Deletes a user identified by their ID.
|
||||
// @Description Deletes a user identified by their ID. When the authenticated actor uses password login, they must send their current password in the X-Password header.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id query string true "User ID"
|
||||
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors"
|
||||
// @Success 200 "User deleted successfully"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users [delete]
|
||||
@@ -199,8 +204,13 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
return http.StatusForbidden, fmt.Errorf("cannot delete users without admin permissions")
|
||||
}
|
||||
|
||||
status, err := verifyActorPasswordForUserActions(r, d)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
// Delete the user
|
||||
err := store.Users.Delete(givenUserId)
|
||||
err = store.Users.Delete(givenUserId)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -209,13 +219,15 @@ func userDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
|
||||
// usersPostHandler creates a new user.
|
||||
// @Summary Create a new user
|
||||
// @Description Adds a new user to the system.
|
||||
// @Description Adds a new user to the system. When the authenticated actor uses password login, they must send their current password in the X-Password header.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors"
|
||||
// @Param data body users.User true "User data to create a new user"
|
||||
// @Success 201 {object} users.User "Created user"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users [post]
|
||||
func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -246,6 +258,11 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusBadRequest, errors.ErrEmptyPassword
|
||||
}
|
||||
|
||||
status, err := verifyActorPasswordForUserActions(r, d)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
|
||||
err = storage.CreateUser(req.User, req.User.Permissions)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
@@ -255,16 +272,77 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusCreated, nil
|
||||
}
|
||||
|
||||
// nonAdminEditableFieldNameSet returns User.NonAdminEditable struct field names (e.g. "Locale", "Preview").
|
||||
func nonAdminEditableFieldNameSet() map[string]struct{} {
|
||||
m := make(map[string]struct{})
|
||||
t := reflect.TypeOf(users.NonAdminEditable{})
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
m[t.Field(i).Name] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// userPutOnlyNonAdminEditableFields reports whether req.Which lists exclusively NonAdminEditable fields,
|
||||
// excluding Password. Empty which or which[0] == "all" means a broad update and returns false.
|
||||
func userPutOnlyNonAdminEditableFields(which []string) bool {
|
||||
if len(which) == 0 || strings.EqualFold(strings.TrimSpace(which[0]), "all") {
|
||||
return false
|
||||
}
|
||||
allowed := nonAdminEditableFieldNameSet()
|
||||
for _, w := range which {
|
||||
f := utils.CapitalizeFirst(strings.TrimSpace(w))
|
||||
if f == "" {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(f, "Password") {
|
||||
return false
|
||||
}
|
||||
if _, ok := allowed[f]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// verifyActorPasswordForUserPut requires URL-encoded X-Password when the authenticated actor uses
|
||||
// password login. Callers should invoke this only when the update requires re-authentication.
|
||||
func verifyActorPasswordForUserActions(r *http.Request, d *requestContext) (int, error) {
|
||||
if d.user.LoginMethod != users.LoginMethodPassword {
|
||||
return 0, nil
|
||||
}
|
||||
encoded := r.Header.Get("X-Password")
|
||||
if encoded == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("X-Password header is required to confirm your password")
|
||||
}
|
||||
plain, err := url.QueryUnescape(encoded)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid password encoding")
|
||||
}
|
||||
if plain == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("X-Password header is required to confirm your password")
|
||||
}
|
||||
actor, err := store.Users.Get(d.user.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
if err := utils.CheckPwd(plain, actor.Password); err != nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid password")
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// userPutHandler updates an existing user's details.
|
||||
// @Summary Update a user's details
|
||||
// @Description Updates the details of a user identified by ID.
|
||||
// @Description Updates the details of a user identified by ID. When the authenticated actor uses password login, they must send their current password in the X-Password header unless the update only touches NonAdminEditable profile fields (not password). Full updates (which empty or "all") or any admin-only field require confirmation.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Param id query string false "user ID to update"
|
||||
// @Param id query string false "usename to update"
|
||||
// @Param X-Password header string false "Actor's current password (URL-encoded); required for password-login actors when updating password, using which=all, or any field outside NonAdminEditable"
|
||||
// @Param data body users.User true "User data to update"
|
||||
// @Success 204 "No Content - User updated successfully"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password when required"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users [put]
|
||||
@@ -316,6 +394,14 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
if d.user.LoginMethod == users.LoginMethodPassword && !userPutOnlyNonAdminEditableFields(req.Which) {
|
||||
var status int
|
||||
status, err = verifyActorPasswordForUserActions(r, d)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
}
|
||||
|
||||
err = store.Users.Update(&req.User, d.user.Permissions.Admin, req.Which...)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
|
||||
@@ -24,6 +24,8 @@ func errToStatus(err error) int {
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, libErrors.ErrInvalidRequestParams):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, libErrors.ErrIsDirectory):
|
||||
return http.StatusMethodNotAllowed
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ const (
|
||||
type iconSize struct {
|
||||
size int
|
||||
name string
|
||||
env *string
|
||||
}
|
||||
|
||||
// determineSourceType determines which type of favicon source we have
|
||||
@@ -153,13 +152,13 @@ func handleRasterFavicon(faviconPath string) ([]byte, error) {
|
||||
func getIconSizesToGenerate() []iconSize {
|
||||
return []iconSize{
|
||||
// Browser favicon (32x32 PNG for Safari/older browsers)
|
||||
{32, "favicon-32x32.png", nil},
|
||||
{32, "favicon-32x32.png"},
|
||||
// PWA icons (PNG required for Apple PWA compatibility)
|
||||
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
|
||||
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
|
||||
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
|
||||
{192, "pwa-icon-192.png"},
|
||||
{256, "pwa-icon-256.png"}, // Also serves as Windows tile
|
||||
{512, "pwa-icon-512.png"},
|
||||
// Platform-specific icons
|
||||
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
|
||||
{180, "apple-touch-icon.png"}, // iOS home screen (required for Apple)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,10 +180,6 @@ func generateIconSizes(sourceData []byte) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update environment variable if specified
|
||||
if icon.env != nil {
|
||||
*icon.env = filepath.Join("icons", icon.name)
|
||||
}
|
||||
generatedCount++
|
||||
}
|
||||
|
||||
@@ -199,7 +194,7 @@ func generateIconSizes(sourceData []byte) error {
|
||||
|
||||
// generateSingleIcon generates a single icon file at the specified size
|
||||
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
|
||||
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
|
||||
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputPath)
|
||||
@@ -233,18 +228,11 @@ func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon
|
||||
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
|
||||
func GeneratePWAIcons() error {
|
||||
logger.Info("Generating PWA icons...")
|
||||
// Set PWA icons directory in cache
|
||||
settings.Env.PWAIconsDir = filepath.Join(settings.Config.Server.CacheDir, "icons")
|
||||
|
||||
// Initialize with default paths (all icons will be generated)
|
||||
settings.Env.PWAIcon192 = "icons/pwa-icon-192.png"
|
||||
settings.Env.PWAIcon256 = "icons/pwa-icon-256.png"
|
||||
settings.Env.PWAIcon512 = "icons/pwa-icon-512.png"
|
||||
|
||||
cacheDir := settings.PWAIconsCacheDir()
|
||||
// Create icons directory with configured permissions
|
||||
// Note: Parent cache directory should already exist from testCacheDirSpeed()
|
||||
if err := os.MkdirAll(settings.Env.PWAIconsDir, fileutils.PermDir); err != nil {
|
||||
logger.Warningf("Failed to create PWA icons directory %s: %v", settings.Env.PWAIconsDir, err)
|
||||
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
|
||||
logger.Warningf("Failed to create PWA icons directory %s: %v", cacheDir, err)
|
||||
}
|
||||
// Load and prepare source data as PNG
|
||||
sourceData, err := loadFaviconSource()
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
|
||||
@@ -36,6 +33,9 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
|
||||
if len(name) > 12 {
|
||||
shortName = name[:12]
|
||||
}
|
||||
if name == "FileBrowser Quantum" {
|
||||
shortName = "FBQ"
|
||||
}
|
||||
|
||||
return PWAManifest{
|
||||
Name: name,
|
||||
@@ -68,7 +68,9 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
|
||||
}
|
||||
}
|
||||
|
||||
// InitializePWAManifest generates and caches the PWA manifest at startup
|
||||
// InitializePWAManifest caches the PWA manifest at startup. Icon URLs always point at
|
||||
// PNGs under public/static/icons/, which GeneratePWAIcons produces from the configured
|
||||
// favicon (custom SVG uses a raster sidecar as the raster source).
|
||||
func InitializePWAManifest() {
|
||||
config := &settings.Config
|
||||
staticURL := config.Server.BaseURL + "public/static"
|
||||
@@ -76,21 +78,9 @@ func InitializePWAManifest() {
|
||||
description := config.Frontend.Description
|
||||
defaultThemeColor := "#455a64"
|
||||
|
||||
// Determine PWA icon URLs based on custom favicon settings
|
||||
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
|
||||
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
|
||||
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
|
||||
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
|
||||
favicon := staticURL + "/favicon"
|
||||
pwaIcon192 = favicon
|
||||
pwaIcon256 = favicon
|
||||
pwaIcon512 = favicon
|
||||
} else if settings.Env.FaviconIsCustom {
|
||||
pwaIcon192 = staticURL + "/icons/pwa-icon-192.png"
|
||||
pwaIcon256 = staticURL + "/icons/pwa-icon-256.png"
|
||||
pwaIcon512 = staticURL + "/icons/pwa-icon-512.png"
|
||||
}
|
||||
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
|
||||
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
|
||||
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
|
||||
|
||||
CachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
|
||||
}
|
||||
@@ -16,44 +16,26 @@ func TestFetchExtendedAttributes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
adjustedPath string
|
||||
combinedPath string
|
||||
files []os.FileInfo
|
||||
opts Options
|
||||
expectHasPreview bool
|
||||
expectMapNotEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "SkipExtendedAttrs returns empty",
|
||||
adjustedPath: "/test/",
|
||||
combinedPath: "/test/",
|
||||
files: []os.FileInfo{},
|
||||
opts: Options{
|
||||
SkipExtendedAttrs: true,
|
||||
Recursive: false,
|
||||
},
|
||||
expectHasPreview: false,
|
||||
name: "SkipExtendedAttrs returns empty",
|
||||
adjustedPath: "/test/",
|
||||
opts: Options{SkipExtendedAttrs: true, Recursive: false},
|
||||
expectMapNotEmpty: false,
|
||||
},
|
||||
{
|
||||
name: "Recursive returns empty",
|
||||
adjustedPath: "/test/",
|
||||
combinedPath: "/test/",
|
||||
files: []os.FileInfo{},
|
||||
opts: Options{
|
||||
SkipExtendedAttrs: false,
|
||||
Recursive: true,
|
||||
},
|
||||
expectHasPreview: false,
|
||||
name: "Recursive returns empty",
|
||||
adjustedPath: "/test/",
|
||||
opts: Options{SkipExtendedAttrs: false, Recursive: true},
|
||||
expectMapNotEmpty: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hasPreview, subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.files, tt.opts)
|
||||
if hasPreview != tt.expectHasPreview {
|
||||
t.Errorf("expected hasPreview=%v, got %v", tt.expectHasPreview, hasPreview)
|
||||
}
|
||||
subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.opts)
|
||||
if (len(subdirMap) > 0) != tt.expectMapNotEmpty {
|
||||
t.Errorf("expected mapNotEmpty=%v, got %v", tt.expectMapNotEmpty, len(subdirMap) > 0)
|
||||
}
|
||||
@@ -61,6 +43,28 @@ func TestFetchExtendedAttributes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchExtendedAttributes_subdirHasPreviewFromMetadata checks folder hasPreview comes from one GetMetadataInfo (non-shallow).
|
||||
func TestFetchExtendedAttributes_subdirHasPreviewFromMetadata(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
deepdir, err := idx.db.GetItem("test", "/subdir/deepdir/")
|
||||
if err != nil || deepdir == nil {
|
||||
t.Fatalf("deepdir row: err=%v", err)
|
||||
}
|
||||
deepdir.HasPreview = true
|
||||
_ = idx.db.InsertItem("test", "/subdir/deepdir/", deepdir)
|
||||
|
||||
subdirMap := idx.fetchExtendedAttributes("/subdir/", Options{
|
||||
SkipExtendedAttrs: false,
|
||||
Recursive: false,
|
||||
})
|
||||
|
||||
if !subdirMap["/subdir/deepdir/"] {
|
||||
t.Errorf("expected hasPreview map for /subdir/deepdir/, got %v", subdirMap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDirectoryName tests the directory name extraction logic
|
||||
func TestGetDirectoryName(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -85,17 +85,17 @@ type Stats struct {
|
||||
LastIndexedUnix int64 `json:"lastIndexedUnixTime"`
|
||||
Complexity uint `json:"complexity"`
|
||||
LastScanned time.Time `json:"lastScanned"`
|
||||
DiskUsed uint64 `json:"used"`
|
||||
UsedAlt uint64 `json:"usedAlt"`
|
||||
UsedAsIndexed uint64 `json:"used"`
|
||||
UsedDisk uint64 `json:"usedAlt"`
|
||||
DiskTotal uint64 `json:"total"`
|
||||
}
|
||||
|
||||
// reduced index is json exposed to the client
|
||||
type ReducedIndex struct {
|
||||
Stats
|
||||
IdxName string `json:"name"`
|
||||
DiskTotal uint64 `json:"total"`
|
||||
Status IndexStatus `json:"status"`
|
||||
Scanners []*ScannerInfo `json:"scanners,omitempty"`
|
||||
IdxName string `json:"name"`
|
||||
Status IndexStatus `json:"status"`
|
||||
Scanners []*ScannerInfo `json:"scanners,omitempty"`
|
||||
}
|
||||
|
||||
type Index struct {
|
||||
@@ -734,50 +734,26 @@ func (idx *Index) GetFsInfoViewableOnly(adjustedPath string, followSymlinks bool
|
||||
})
|
||||
}
|
||||
|
||||
// fetchExtendedAttributes fetches hasPreview for the current directory and batch fetches for subdirectories
|
||||
func (idx *Index) fetchExtendedAttributes(indexPath string, files []os.FileInfo, opts Options) (bool, map[string]bool) {
|
||||
hasPreview := false
|
||||
// fetchExtendedAttributes loads indexed subfolder has_preview for the current directory from GetMetadataInfo (database).
|
||||
// The listed directory's own HasPreview is derived only in GetDirInfoCore from children (never from a stale parent DB row).
|
||||
func (idx *Index) fetchExtendedAttributes(indexPath string, opts Options) map[string]bool {
|
||||
subdirHasPreviewMap := make(map[string]bool)
|
||||
|
||||
if opts.SkipExtendedAttrs || opts.Recursive {
|
||||
return hasPreview, subdirHasPreviewMap
|
||||
return subdirHasPreviewMap
|
||||
}
|
||||
|
||||
// Fetch hasPreview for current directory
|
||||
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, true)
|
||||
if exists {
|
||||
hasPreview = realDirInfo.HasPreview
|
||||
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, false)
|
||||
if !exists {
|
||||
return subdirHasPreviewMap
|
||||
}
|
||||
|
||||
// Batch fetch hasPreview for all subdirectories
|
||||
var subdirPaths []string
|
||||
for _, file := range files {
|
||||
if !iteminfo.IsDirectory(file) {
|
||||
continue
|
||||
}
|
||||
baseName := file.Name()
|
||||
if indexPath == "/" {
|
||||
if !idx.shouldInclude(baseName) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if omitList[baseName] {
|
||||
continue
|
||||
}
|
||||
dirPath := indexPath + baseName
|
||||
childIndexPath := utils.AddTrailingSlashIfNotExists(dirPath)
|
||||
subdirPaths = append(subdirPaths, childIndexPath)
|
||||
for _, folder := range realDirInfo.Folders {
|
||||
key := utils.AddTrailingSlashIfNotExists(indexPath + folder.Name)
|
||||
subdirHasPreviewMap[key] = folder.HasPreview
|
||||
}
|
||||
|
||||
if len(subdirPaths) > 0 {
|
||||
var err error
|
||||
subdirHasPreviewMap, err = idx.db.GetHasPreviewBatch(idx.Name, subdirPaths)
|
||||
if err != nil {
|
||||
subdirHasPreviewMap = make(map[string]bool)
|
||||
}
|
||||
}
|
||||
|
||||
return hasPreview, subdirHasPreviewMap
|
||||
return subdirHasPreviewMap
|
||||
}
|
||||
|
||||
// processDirectoryItem processes a directory item and returns the itemInfo, size, and whether it should be counted
|
||||
@@ -825,11 +801,7 @@ func (idx *Index) processDirectoryItem(file os.FileInfo, indexPath string, subdi
|
||||
if !opts.SkipExtendedAttrs && subdirHasPreviewMap != nil {
|
||||
if hasPreviewValue, exists := subdirHasPreviewMap[childIndexPath]; exists {
|
||||
itemInfo.HasPreview = hasPreviewValue
|
||||
} else {
|
||||
itemInfo.HasPreview = false
|
||||
}
|
||||
} else {
|
||||
itemInfo.HasPreview = false
|
||||
}
|
||||
|
||||
return itemInfo, itemInfo.Size, true
|
||||
@@ -875,9 +847,7 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
|
||||
if !usedCachedPreview {
|
||||
setFilePreviewFlags(itemInfo, fullCombined)
|
||||
}
|
||||
if itemInfo.HasPreview && iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo) {
|
||||
bubblesUpHasPreview = true
|
||||
}
|
||||
bubblesUpHasPreview = iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo)
|
||||
} else {
|
||||
itemInfo.HasPreview = false
|
||||
}
|
||||
@@ -894,8 +864,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fetch extended attributes (hasPreview for current dir and subdirs)
|
||||
hasPreview, subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, files, opts)
|
||||
subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, opts)
|
||||
|
||||
baseName := filepath.Base(indexPath)
|
||||
if indexPath == "/" {
|
||||
@@ -906,6 +875,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
fileInfos := []iteminfo.ExtendedItemInfo{}
|
||||
dirInfos := []iteminfo.ItemInfo{}
|
||||
processedCount := 0
|
||||
hasPreview := false
|
||||
|
||||
for _, file := range files {
|
||||
subfileBaseName := file.Name()
|
||||
@@ -960,12 +930,12 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
}
|
||||
} else {
|
||||
itemInfo, size, shouldCount, bubblesUp := idx.processFileItem(file, indexPath, opts, scanner)
|
||||
if shouldCount {
|
||||
totalSize += size
|
||||
}
|
||||
if bubblesUp {
|
||||
hasPreview = true
|
||||
}
|
||||
if shouldCount {
|
||||
totalSize += size
|
||||
}
|
||||
fileInfos = append(fileInfos, iteminfo.ExtendedItemInfo{ItemInfo: *itemInfo})
|
||||
if opts.IsRoutineScan {
|
||||
idx.incrementScannerFiles()
|
||||
@@ -1132,6 +1102,7 @@ func (idx *Index) updateFolderSizeAndParents(path string, newSize uint64, previo
|
||||
if parentDir == "" {
|
||||
break // Reached the top
|
||||
}
|
||||
// Keys in folderSizes match indexing paths (trailing slash for non-root dirs; root stays "/").
|
||||
if parentDir != "/" {
|
||||
parentDir = utils.AddTrailingSlashIfNotExists(parentDir)
|
||||
}
|
||||
@@ -1158,22 +1129,6 @@ func (idx *Index) updateFolderSizeAndParents(path string, newSize uint64, previo
|
||||
}
|
||||
}
|
||||
|
||||
// SetFolderSize sets the size for a directory in the in-memory map
|
||||
func (idx *Index) SetFolderSize(path string, newSize uint64) {
|
||||
idx.folderSizesMu.Lock()
|
||||
defer idx.folderSizesMu.Unlock()
|
||||
idx.folderSizes[path] = newSize
|
||||
idx.folderSizesUnsynced[path] = struct{}{} // Mark as changed for next DB sync
|
||||
}
|
||||
|
||||
// GetFolderSize retrieves the size for a directory from the in-memory map
|
||||
func (idx *Index) GetFolderSize(path string) (uint64, bool) {
|
||||
idx.folderSizesMu.RLock()
|
||||
defer idx.folderSizesMu.RUnlock()
|
||||
size, exists := idx.folderSizes[path]
|
||||
return size, exists
|
||||
}
|
||||
|
||||
// GetFolderSizeForDisplay retrieves folder size with appropriate formatting based on config
|
||||
func (idx *Index) GetFolderSizeForDisplay(path string) int64 {
|
||||
size, exists := idx.GetFolderSize(path)
|
||||
@@ -1204,20 +1159,6 @@ func (idx *Index) getFileSizeForDisplay(file os.FileInfo, realPath string) int64
|
||||
return getDiskUsage(file, realPath, false)
|
||||
}
|
||||
|
||||
// RecursiveUpdateDirSizes is now a wrapper for backwards compatibility
|
||||
// This maintains API compatibility while using the new optimized approach
|
||||
func (idx *Index) RecursiveUpdateDirSizes(path string, previousSize uint64) {
|
||||
currentSize, exists := idx.GetFolderSize(path)
|
||||
if !exists {
|
||||
logger.Debugf("[FOLDER_SIZE] Path %s not found in folderSizes map", path)
|
||||
return
|
||||
}
|
||||
|
||||
if currentSize != previousSize {
|
||||
idx.updateFolderSizeAndParents(path, currentSize, previousSize, false) // updateTarget=false, only update parents
|
||||
}
|
||||
}
|
||||
|
||||
// SyncFolderSizesToDB syncs in-memory folder sizes to the database after a scan completes
|
||||
// Only syncs folders marked as dirty (changed) and processes in batches to avoid large SQL statements
|
||||
func (idx *Index) SyncFolderSizesToDB() error {
|
||||
@@ -1532,18 +1473,15 @@ func (idx *Index) IsNeverWatchPath(adjustedPath string) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
type DiskUsage struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
}
|
||||
|
||||
func (idx *Index) SetUsage(totalBytes uint64) {
|
||||
func (idx *Index) SetUsage(totalDiskSize, partitionUsed, UsedAsIndexed uint64) {
|
||||
if settings.Config.Frontend.DisableUsedPercentage {
|
||||
return
|
||||
}
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
idx.DiskTotal = totalBytes
|
||||
idx.DiskTotal = totalDiskSize
|
||||
idx.UsedAsIndexed = UsedAsIndexed
|
||||
idx.UsedDisk = partitionUsed
|
||||
}
|
||||
|
||||
func (idx *Index) SetStatus(status IndexStatus) error {
|
||||
@@ -1628,14 +1566,13 @@ func (idx *Index) shouldInclude(baseName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Save persists the index and scanner information to the database
|
||||
func (idx *Index) Save() error {
|
||||
// writePersistedIndexInfo writes scanners, stats, and disk totals to Bolt (no SSE broadcast).
|
||||
func (idx *Index) writePersistedIndexInfo() error {
|
||||
if indexingStorage == nil {
|
||||
return nil // No storage available, skip persistence
|
||||
return nil
|
||||
}
|
||||
|
||||
idx.mu.RLock()
|
||||
// Collect scanner information
|
||||
scanners := make(map[string]*dbindex.PersistedScannerInfo)
|
||||
for path, scanner := range idx.scanners {
|
||||
scanners[path] = &dbindex.PersistedScannerInfo{
|
||||
@@ -1650,32 +1587,37 @@ func (idx *Index) Save() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Get current index stats
|
||||
complexity := idx.getComplexityUnlocked()
|
||||
numDirs := idx.getNumDirsUnlocked()
|
||||
numFiles := idx.getNumFilesUnlocked()
|
||||
usedAsIndexed := idx.UsedAsIndexed
|
||||
usedDisk := idx.UsedDisk
|
||||
diskTotal := idx.DiskTotal
|
||||
idx.mu.RUnlock()
|
||||
|
||||
// Create IndexInfo for persistence
|
||||
info := &dbindex.IndexInfo{
|
||||
Path: idx.Path, // Use real filesystem path as key
|
||||
Source: idx.Name,
|
||||
Complexity: complexity,
|
||||
NumDirs: numDirs,
|
||||
NumFiles: numFiles,
|
||||
Scanners: scanners,
|
||||
Path: idx.Path,
|
||||
Source: idx.Name,
|
||||
Complexity: complexity,
|
||||
NumDirs: numDirs,
|
||||
NumFiles: numFiles,
|
||||
UsedAsIndexed: usedAsIndexed,
|
||||
UsedDisk: usedDisk,
|
||||
DiskTotal: diskTotal,
|
||||
Scanners: scanners,
|
||||
}
|
||||
|
||||
err := indexingStorage.Save(info)
|
||||
if err != nil {
|
||||
return 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
|
||||
}
|
||||
|
||||
// Send SSE update event after successful save
|
||||
if err := idx.SendSourceUpdateEvent(); err != nil {
|
||||
logger.Errorf("[%s] Failed to send source update event: %v", idx.Name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1703,6 +1645,9 @@ func (idx *Index) Load() error {
|
||||
idx.Complexity = info.Complexity
|
||||
idx.NumDirs = info.NumDirs
|
||||
idx.NumFiles = info.NumFiles
|
||||
idx.UsedAsIndexed = info.UsedAsIndexed
|
||||
idx.UsedDisk = info.UsedDisk
|
||||
idx.DiskTotal = info.DiskTotal
|
||||
|
||||
// Restore scanner information (will be applied when scanners are created)
|
||||
// Store in a temporary map that setupMultiScanner can use
|
||||
|
||||
@@ -502,7 +502,6 @@ func (s *Scanner) calculateSleepTime() time.Duration {
|
||||
// updateSchedule adjusts the scanner's schedule based on whether files changed
|
||||
func (s *Scanner) updateSchedule() {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
|
||||
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
|
||||
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
|
||||
@@ -539,7 +538,10 @@ func (s *Scanner) updateSchedule() {
|
||||
s.currentSchedule = len(scanSchedule) - 1
|
||||
}
|
||||
|
||||
// Persist index after schedule update (happens after each scan)
|
||||
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)
|
||||
}
|
||||
@@ -549,8 +551,6 @@ func (s *Scanner) updateSchedule() {
|
||||
// 0: unknown
|
||||
func (s *Scanner) updateComplexity() {
|
||||
s.statsMu.Lock()
|
||||
defer s.statsMu.Unlock()
|
||||
|
||||
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
|
||||
|
||||
// Set smartModifier based on complexity level
|
||||
@@ -559,7 +559,9 @@ func (s *Scanner) updateComplexity() {
|
||||
} else {
|
||||
s.smartModifier = 0
|
||||
}
|
||||
// Persist index after complexity update (happens after full scans)
|
||||
s.statsMu.Unlock()
|
||||
|
||||
// Persist after full scans — release statsMu before Save (same ordering as updateSchedule).
|
||||
if err := s.idx.Save(); err != nil {
|
||||
logger.Errorf("Failed to save index after complexity update: %v", err)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,9 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
if idx.mock {
|
||||
return nil
|
||||
}
|
||||
reducedIndex, err := GetIndexInfo(idx.Name, true)
|
||||
// Avoid force=true: otherwise GetIndexInfo invalidates DiskUsageCache, recomputes, and writePersistedIndexInfo
|
||||
// nests under Save recursion; false uses cached probe / current idx totals for the broadcast payload.
|
||||
reducedIndex, err := GetIndexInfo(idx.Name, false)
|
||||
if err != nil {
|
||||
logger.Errorf("[%s] Error getting index info: %v", idx.Name, err)
|
||||
return err
|
||||
@@ -226,21 +228,12 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
// setupMultiScanner creates and starts the multi-scanner system
|
||||
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
|
||||
func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
// Load persisted index and scanner information
|
||||
// Load persisted index-level stats from Bolt (complexity, numDirs/Files, used/usedAlt/total, etc.)
|
||||
// Scanner-level stats below use a second GetByPath only for the Scanners map.
|
||||
if err := idx.Load(); err != nil {
|
||||
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
|
||||
}
|
||||
|
||||
// Load existing folder sizes from database to avoid marking everything as dirty on startup
|
||||
existingSizes, err := idx.db.LoadFolderSizes(idx.Name)
|
||||
if err != nil {
|
||||
logger.Errorf("[INIT] Failed to load existing folder sizes for [%s]: %v", idx.Name, err)
|
||||
} else {
|
||||
idx.folderSizesMu.Lock()
|
||||
idx.folderSizes = existingSizes
|
||||
idx.folderSizesMu.Unlock()
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
idx.scanners = make(map[string]*Scanner)
|
||||
idx.mu.Unlock()
|
||||
|
||||
@@ -30,6 +30,8 @@ type FileInfo struct {
|
||||
Files []ExtendedItemInfo `json:"files,omitempty"` // files in the directory with optional metadata
|
||||
Folders []ItemInfo `json:"folders,omitempty"` // folders in the directory
|
||||
Path string `json:"path,omitempty"` // path scoped to the associated index
|
||||
// IsDir is the index DB is_dir column; Type alone can disagree (legacy or malformed rows).
|
||||
IsDir bool `json:"-"`
|
||||
}
|
||||
|
||||
// MediaMetadata contains metadata extracted from audio and video files
|
||||
|
||||
@@ -405,6 +405,9 @@ func IsDirectory(fileInfo os.FileInfo) bool {
|
||||
// Text files, office documents, and PDFs should NOT bubble up to folder previews.
|
||||
// This ensures consistency between indexing and preview generation.
|
||||
func ShouldBubbleUpToFolderPreview(item ItemInfo) bool {
|
||||
if item.Type == "directory" || !item.HasPreview {
|
||||
return false
|
||||
}
|
||||
// Get the simple type (e.g., "image", "video", "text")
|
||||
simpleType := strings.Split(item.Type, "/")[0]
|
||||
// Text files should NOT bubble up
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package iteminfo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShouldBubbleUpToFolderPreview(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item ItemInfo
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "directory type never bubbles",
|
||||
item: ItemInfo{Name: "sub", Type: "directory"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "text markdown does not bubble",
|
||||
item: ItemInfo{Name: "README.md", Type: "text/markdown"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain text does not bubble",
|
||||
item: ItemInfo{Name: "LICENSE", Type: "text/plain; charset=utf-8"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "image does not bubble without HasPreview",
|
||||
item: ItemInfo{Name: "a.png", Type: "image/png", HasPreview: false},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "image bubbles when HasPreview",
|
||||
item: ItemInfo{Name: "a.png", Type: "image/png", HasPreview: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "video bubbles when HasPreview",
|
||||
item: ItemInfo{Name: "clip.mp4", Type: "video/mp4", HasPreview: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "audio bubbles when HasPreview (e.g. album art)",
|
||||
item: ItemInfo{Name: "track.mp3", Type: "audio/mpeg", HasPreview: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OnlyOffice-backed extension does not bubble",
|
||||
item: ItemInfo{Name: "report.docx", Type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown blob type does not bubble",
|
||||
item: ItemInfo{Name: "data.bin", Type: "application/octet-stream"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty type does not bubble",
|
||||
item: ItemInfo{Name: "weird", Type: ""},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ShouldBubbleUpToFolderPreview(tt.item); got != tt.want {
|
||||
t.Fatalf("ShouldBubbleUpToFolderPreview() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+50
-28
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
|
||||
// scanner parameter is optional - if nil (API refresh), directly inserts to database
|
||||
// UpdateMetadata persists a completed directory listing to the index. The directory row's has_preview
|
||||
// scanner is optional — if nil (API refresh), inserts run immediately; otherwise items batch until flush.
|
||||
func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo, scanner *Scanner) bool {
|
||||
items := make([]*iteminfo.FileInfo, 0, len(info.Files)+len(info.Folders)+1)
|
||||
dirItem := *info
|
||||
@@ -296,8 +296,9 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
return ReducedIndex{}, fmt.Errorf("index %s not found", sourceName)
|
||||
}
|
||||
|
||||
// Only update disk total if cache is missing or explicitly forced
|
||||
// The "used" value comes from totalSize and is always current
|
||||
// Only update disk total if cache is missing or explicitly forced.
|
||||
// Indexed "used" sums root-level files from SQLite; top-level folders use idx.folderSizes (scanned rollup)
|
||||
// because directory size columns in SQLite can lag folderSizes/SyncFolderSizesToDB.
|
||||
sourcePath := idx.Path
|
||||
cacheKey := "usageCache-" + sourceName
|
||||
if forceCacheRefresh {
|
||||
@@ -307,27 +308,40 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
_, ok = utils.DiskUsageCache.Get(cacheKey)
|
||||
if !ok {
|
||||
// Only fetch disk total and used if not cached (this is expensive, so we cache it)
|
||||
totalBytes, err := fileutils.GetPartitionSize(sourcePath)
|
||||
totalPartitionSize, err := fileutils.GetPartitionSize(sourcePath)
|
||||
if err != nil {
|
||||
idx.mu.Lock()
|
||||
idx.Status = UNAVAILABLE
|
||||
idx.mu.Unlock()
|
||||
return ReducedIndex{}, fmt.Errorf("error getting disk usage for %s: %v", sourcePath, err)
|
||||
logger.Errorf("Failed to get partition size for index %s: %v", sourceName, err)
|
||||
}
|
||||
itemInfo, dbErr := idx.db.GetDirectoryChildren(idx.Name, "/")
|
||||
if dbErr != nil {
|
||||
logger.Debugf("GetIndexInfo: could not read root listing from index DB for %s: %v", sourceName, dbErr)
|
||||
}
|
||||
var indexedSizeFromDB uint64
|
||||
for _, item := range itemInfo {
|
||||
isFolder := item.IsDir || item.Type == "directory"
|
||||
if !isFolder {
|
||||
indexedSizeFromDB += uint64(item.Size)
|
||||
continue
|
||||
}
|
||||
folderKey := utils.AddTrailingSlashIfNotExists(item.Path)
|
||||
if agg, ok := idx.GetFolderSize(folderKey); ok {
|
||||
indexedSizeFromDB += agg
|
||||
} else {
|
||||
indexedSizeFromDB += uint64(item.Size)
|
||||
}
|
||||
}
|
||||
|
||||
idx.SetUsage(totalBytes)
|
||||
|
||||
// Also fetch OS-reported partition used space (total - free)
|
||||
usedAlt, err := fileutils.GetPartitionUsed(sourcePath)
|
||||
partitionUsed, err := fileutils.GetPartitionUsed(sourcePath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get partition used space for index %s: %v", sourceName, err)
|
||||
usedAlt = 0
|
||||
}
|
||||
idx.mu.Lock()
|
||||
idx.UsedAlt = usedAlt
|
||||
idx.mu.Unlock()
|
||||
|
||||
idx.SetUsage(totalPartitionSize, partitionUsed, indexedSizeFromDB)
|
||||
utils.DiskUsageCache.Set(cacheKey, true)
|
||||
if indexingStorage != nil {
|
||||
if err := idx.writePersistedIndexInfo(); err != nil {
|
||||
logger.Warningf("GetIndexInfo: failed to persist index disk stats for %s: %v", sourceName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build scanner info for client
|
||||
@@ -348,16 +362,8 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
})
|
||||
}
|
||||
reducedIdx := idx.ReducedIndex
|
||||
var diskUsed uint64
|
||||
rootTotal, ok := idx.GetFolderSize("/")
|
||||
if !ok {
|
||||
logger.Errorf("Failed to get root-level indexed total for index %s", sourceName)
|
||||
diskUsed = 0
|
||||
} else {
|
||||
diskUsed = rootTotal
|
||||
}
|
||||
reducedIdx.DiskUsed = diskUsed
|
||||
reducedIdx.UsedAlt = idx.UsedAlt
|
||||
reducedIdx.UsedAsIndexed = idx.UsedAsIndexed
|
||||
reducedIdx.UsedDisk = idx.UsedDisk
|
||||
reducedIdx.DiskTotal = idx.DiskTotal
|
||||
reducedIdx.Scanners = scannerInfos
|
||||
reducedIdx.NumDirs = idx.getNumDirsUnlocked()
|
||||
@@ -372,3 +378,19 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
idx.mu.RUnlock()
|
||||
return reducedIdx, nil
|
||||
}
|
||||
|
||||
// SetFolderSize sets the size for a directory in the in-memory map
|
||||
func (idx *Index) SetFolderSize(path string, newSize uint64) {
|
||||
idx.folderSizesMu.Lock()
|
||||
defer idx.folderSizesMu.Unlock()
|
||||
idx.folderSizes[path] = newSize
|
||||
idx.folderSizesUnsynced[path] = struct{}{} // Mark as changed for next DB sync
|
||||
}
|
||||
|
||||
// GetFolderSize retrieves the size for a directory from the in-memory map
|
||||
func (idx *Index) GetFolderSize(path string) (uint64, bool) {
|
||||
idx.folderSizesMu.RLock()
|
||||
defer idx.folderSizesMu.RUnlock()
|
||||
size, exists := idx.folderSizes[path]
|
||||
return size, exists
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
.card,
|
||||
#listingView.gallery .item,
|
||||
#listingView.normal .item,
|
||||
.listing-item,
|
||||
.floating-window,
|
||||
.button,
|
||||
button,
|
||||
.input,
|
||||
|
||||
@@ -1090,7 +1090,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Path to the directory",
|
||||
"description": "Path to the directory or file",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -1101,6 +1101,12 @@ const docTemplate = `{
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "When true, include embedded album art bytes in audio metadata",
|
||||
"name": "albumArt",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3070,7 +3076,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Updates the details of a user identified by ID.",
|
||||
"description": "Updates the details of a user identified by ID. When the authenticated actor uses password login, they must send their current password in the X-Password header unless the update only touches NonAdminEditable profile fields (not password). Full updates (which empty or \"all\") or any admin-only field require confirmation.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3091,6 +3097,12 @@ const docTemplate = `{
|
||||
"name": "id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required for password-login actors when updating password, using which=all, or any field outside NonAdminEditable",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "User data to update",
|
||||
"name": "data",
|
||||
@@ -3114,6 +3126,15 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
@@ -3135,7 +3156,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Adds a new user to the system.",
|
||||
"description": "Adds a new user to the system. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3147,6 +3168,12 @@ const docTemplate = `{
|
||||
],
|
||||
"summary": "Create a new user",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required for password-login actors",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "User data to create a new user",
|
||||
"name": "data",
|
||||
@@ -3173,6 +3200,15 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
@@ -3185,7 +3221,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes a user identified by their ID.",
|
||||
"description": "Deletes a user identified by their ID. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3203,12 +3239,27 @@ const docTemplate = `{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required for password-login actors",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User deleted successfully"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
@@ -3272,6 +3323,12 @@ const docTemplate = `{
|
||||
"description": "Path within the share",
|
||||
"name": "path",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "When true, include embedded album art bytes in audio metadata (heavier)",
|
||||
"name": "albumArt",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -5225,7 +5282,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"secret": {
|
||||
"description": "secret: shared secret key for verifying JWT token signatures (required)",
|
||||
"description": "Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)",
|
||||
"type": "string"
|
||||
},
|
||||
"userGroups": {
|
||||
@@ -5718,7 +5775,7 @@ const docTemplate = `{
|
||||
]
|
||||
},
|
||||
"createUserDir": {
|
||||
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
|
||||
"description": "create a user directory for each user under defaultUserScope + username",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultEnabled": {
|
||||
@@ -6822,6 +6879,10 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/users.SourceScope"
|
||||
}
|
||||
},
|
||||
"showCopyPath": {
|
||||
"description": "show copy path action in the context menu",
|
||||
"type": "boolean"
|
||||
},
|
||||
"showFirstLogin": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -1079,7 +1079,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Path to the directory",
|
||||
"description": "Path to the directory or file",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -1090,6 +1090,12 @@
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "When true, include embedded album art bytes in audio metadata",
|
||||
"name": "albumArt",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3059,7 +3065,7 @@
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Updates the details of a user identified by ID.",
|
||||
"description": "Updates the details of a user identified by ID. When the authenticated actor uses password login, they must send their current password in the X-Password header unless the update only touches NonAdminEditable profile fields (not password). Full updates (which empty or \"all\") or any admin-only field require confirmation.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3080,6 +3086,12 @@
|
||||
"name": "id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required for password-login actors when updating password, using which=all, or any field outside NonAdminEditable",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "User data to update",
|
||||
"name": "data",
|
||||
@@ -3103,6 +3115,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
@@ -3124,7 +3145,7 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Adds a new user to the system.",
|
||||
"description": "Adds a new user to the system. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3136,6 +3157,12 @@
|
||||
],
|
||||
"summary": "Create a new user",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required for password-login actors",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
},
|
||||
{
|
||||
"description": "User data to create a new user",
|
||||
"name": "data",
|
||||
@@ -3162,6 +3189,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
@@ -3174,7 +3210,7 @@
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes a user identified by their ID.",
|
||||
"description": "Deletes a user identified by their ID. When the authenticated actor uses password login, they must send their current password in the X-Password header.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3192,12 +3228,27 @@
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Actor's current password (URL-encoded); required for password-login actors",
|
||||
"name": "X-Password",
|
||||
"in": "header"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User deleted successfully"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
@@ -3261,6 +3312,12 @@
|
||||
"description": "Path within the share",
|
||||
"name": "path",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "When true, include embedded album art bytes in audio metadata (heavier)",
|
||||
"name": "albumArt",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -5214,7 +5271,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"secret": {
|
||||
"description": "secret: shared secret key for verifying JWT token signatures (required)",
|
||||
"description": "Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)",
|
||||
"type": "string"
|
||||
},
|
||||
"userGroups": {
|
||||
@@ -5707,7 +5764,7 @@
|
||||
]
|
||||
},
|
||||
"createUserDir": {
|
||||
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
|
||||
"description": "create a user directory for each user under defaultUserScope + username",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultEnabled": {
|
||||
@@ -6811,6 +6868,10 @@
|
||||
"$ref": "#/definitions/users.SourceScope"
|
||||
}
|
||||
},
|
||||
"showCopyPath": {
|
||||
"description": "show copy path action in the context menu",
|
||||
"type": "boolean"
|
||||
},
|
||||
"showFirstLogin": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -801,8 +801,8 @@ definitions:
|
||||
to logout url. Custom logout query params are respected.
|
||||
type: string
|
||||
secret:
|
||||
description: 'secret: shared secret key for verifying JWT token signatures
|
||||
(required)'
|
||||
description: Shared secret key/bytes for verifying JWT token signatures (required,
|
||||
eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
|
||||
type: string
|
||||
userGroups:
|
||||
description: if set, only users in these groups are allowed to log in. Blocks
|
||||
@@ -1177,8 +1177,8 @@ definitions:
|
||||
- $ref: '#/definitions/settings.ConditionalFilter'
|
||||
description: 'deprecated: use source.rules instead'
|
||||
createUserDir:
|
||||
description: 'deprecated: create a user directory for each user under defaultUserScope
|
||||
+ username (always true now)'
|
||||
description: create a user directory for each user under defaultUserScope
|
||||
+ username
|
||||
type: boolean
|
||||
defaultEnabled:
|
||||
description: should be added as a default source for new users?
|
||||
@@ -1984,6 +1984,9 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/users.SourceScope'
|
||||
type: array
|
||||
showCopyPath:
|
||||
description: show copy path action in the context menu
|
||||
type: boolean
|
||||
showFirstLogin:
|
||||
type: boolean
|
||||
showHidden:
|
||||
@@ -2772,7 +2775,7 @@ paths:
|
||||
description: Same ExtendedFileInfo as resources GET with metadata=true (typically
|
||||
used for directories).
|
||||
parameters:
|
||||
- description: Path to the directory
|
||||
- description: Path to the directory or file
|
||||
in: query
|
||||
name: path
|
||||
required: true
|
||||
@@ -2782,6 +2785,10 @@ paths:
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: When true, include embedded album art bytes in audio metadata
|
||||
in: query
|
||||
name: albumArt
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -4129,18 +4136,31 @@ paths:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Deletes a user identified by their ID.
|
||||
description: Deletes a user identified by their ID. When the authenticated actor
|
||||
uses password login, they must send their current password in the X-Password
|
||||
header.
|
||||
parameters:
|
||||
- description: User ID
|
||||
in: query
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Actor's current password (URL-encoded); required for password-login
|
||||
actors
|
||||
in: header
|
||||
name: X-Password
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: User deleted successfully
|
||||
"401":
|
||||
description: Unauthorized - invalid or missing actor password when required
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
@@ -4197,8 +4217,14 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Adds a new user to the system.
|
||||
description: Adds a new user to the system. When the authenticated actor uses
|
||||
password login, they must send their current password in the X-Password header.
|
||||
parameters:
|
||||
- description: Actor's current password (URL-encoded); required for password-login
|
||||
actors
|
||||
in: header
|
||||
name: X-Password
|
||||
type: string
|
||||
- description: User data to create a new user
|
||||
in: body
|
||||
name: data
|
||||
@@ -4218,6 +4244,12 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized - invalid or missing actor password when required
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
@@ -4230,7 +4262,11 @@ paths:
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Updates the details of a user identified by ID.
|
||||
description: Updates the details of a user identified by ID. When the authenticated
|
||||
actor uses password login, they must send their current password in the X-Password
|
||||
header unless the update only touches NonAdminEditable profile fields (not
|
||||
password). Full updates (which empty or "all") or any admin-only field require
|
||||
confirmation.
|
||||
parameters:
|
||||
- description: user ID to update
|
||||
in: query
|
||||
@@ -4240,6 +4276,11 @@ paths:
|
||||
in: query
|
||||
name: id
|
||||
type: string
|
||||
- description: Actor's current password (URL-encoded); required for password-login
|
||||
actors when updating password, using which=all, or any field outside NonAdminEditable
|
||||
in: header
|
||||
name: X-Password
|
||||
type: string
|
||||
- description: User data to update
|
||||
in: body
|
||||
name: data
|
||||
@@ -4255,6 +4296,12 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized - invalid or missing actor password when required
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
@@ -4295,6 +4342,11 @@ paths:
|
||||
in: query
|
||||
name: path
|
||||
type: string
|
||||
- description: When true, include embedded album art bytes in audio metadata
|
||||
(heavier)
|
||||
in: query
|
||||
name: albumArt
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
|
||||
Generated
+239
-307
@@ -11,7 +11,6 @@
|
||||
"@onlyoffice/document-editor-vue": "^1.4.0",
|
||||
"ace-builds": "^1.24.2",
|
||||
"axios": "^1.13.5",
|
||||
"clipboard": "^2.0.4",
|
||||
"css-vars-ponyfill": "^2.4.3",
|
||||
"dompurify": "^3.2.4",
|
||||
"epubjs": "^0.3.93",
|
||||
@@ -36,7 +35,7 @@
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"deepl-node": "^1.18.0",
|
||||
"deepl-node": "^1.16.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-vue": "^9.24.0",
|
||||
@@ -1127,13 +1126,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -1166,9 +1165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.1.tgz",
|
||||
"integrity": "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1180,9 +1179,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.1.tgz",
|
||||
"integrity": "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1194,9 +1193,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.1.tgz",
|
||||
"integrity": "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1208,9 +1207,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.1.tgz",
|
||||
"integrity": "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1222,9 +1221,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.1.tgz",
|
||||
"integrity": "sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1236,9 +1235,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.1.tgz",
|
||||
"integrity": "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1250,9 +1249,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.1.tgz",
|
||||
"integrity": "sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1264,9 +1263,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.1.tgz",
|
||||
"integrity": "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1278,9 +1277,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1292,9 +1291,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1306,9 +1305,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1320,9 +1319,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1334,9 +1333,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1348,9 +1347,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1362,9 +1361,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1376,9 +1375,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1390,9 +1389,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1404,9 +1403,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1418,9 +1417,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1432,9 +1431,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.1.tgz",
|
||||
"integrity": "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
|
||||
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1446,9 +1445,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.1.tgz",
|
||||
"integrity": "sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==",
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1460,9 +1459,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.1.tgz",
|
||||
"integrity": "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1474,9 +1473,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.1.tgz",
|
||||
"integrity": "sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1488,9 +1487,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.1.tgz",
|
||||
"integrity": "sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1502,9 +1501,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.1.tgz",
|
||||
"integrity": "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1551,9 +1550,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1960,53 +1959,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
|
||||
"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
|
||||
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/shared": "3.5.30",
|
||||
"@babel/parser": "^7.29.2",
|
||||
"@vue/shared": "3.5.33",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
|
||||
"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
|
||||
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
"@vue/compiler-core": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
|
||||
"integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/compiler-core": "3.5.30",
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-ssr": "3.5.30",
|
||||
"@vue/shared": "3.5.30",
|
||||
"@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",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss": "^8.5.10",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
|
||||
"integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
|
||||
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
"@vue/compiler-dom": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-vue2": {
|
||||
@@ -2077,60 +2076,59 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
|
||||
"integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
|
||||
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.30"
|
||||
"@vue/shared": "3.5.33"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
|
||||
"integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
|
||||
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
"@vue/reactivity": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
|
||||
"integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
|
||||
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.30",
|
||||
"@vue/runtime-core": "3.5.30",
|
||||
"@vue/shared": "3.5.30",
|
||||
"@vue/reactivity": "3.5.33",
|
||||
"@vue/runtime-core": "3.5.33",
|
||||
"@vue/shared": "3.5.33",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
|
||||
"integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
|
||||
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
"@vue/compiler-ssr": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.30"
|
||||
"vue": "3.5.33"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
|
||||
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
|
||||
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.7.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz",
|
||||
"integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==",
|
||||
"deprecated": "this version is no longer supported, please update to at least 0.8.*",
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -2165,16 +2163,6 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||
"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",
|
||||
@@ -2186,9 +2174,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2269,14 +2257,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@@ -2319,9 +2307,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -2445,17 +2433,6 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/clipboard": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz",
|
||||
"integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"good-listener": "^1.2.2",
|
||||
"select": "^1.1.2",
|
||||
"tiny-emitter": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2660,18 +2637,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepl-node": {
|
||||
"version": "1.24.0",
|
||||
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.24.0.tgz",
|
||||
"integrity": "sha512-vZ9jUpzJRvFamgVOfm1LDy3YYJ7k8FhxtAX9whR92EFshLIP9JlYS0HFwXL5yYsfqzXdb/wssGRSWvR48t7nSg==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.16.0.tgz",
|
||||
"integrity": "sha512-sYrh8UngVHKBrgffLPl+ng065mlC+Ep3LBdSN3xqDBd4KdBZzgvdxHiHd9Pm9xQCI5pwyuCHyq0cDUW1T3vQUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=12.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.7.4",
|
||||
"form-data": "^3.0.0",
|
||||
"loglevel": ">=1.6.2",
|
||||
"uuid": "^8.3.2"
|
||||
"loglevel": ">=1.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
@@ -2703,12 +2678,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delegate": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
|
||||
"integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||
@@ -2742,9 +2711,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
|
||||
"integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -3494,9 +3463,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -3710,15 +3679,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/good-listener": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
|
||||
"integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delegate": "^3.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -3783,9 +3743,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -4170,9 +4130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4268,9 +4228,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -4351,15 +4311,6 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth/node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
@@ -4421,9 +4372,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4455,12 +4406,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -4818,9 +4769,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4850,13 +4801,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -4869,9 +4820,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -4895,9 +4846,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -4953,10 +4904,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -4969,9 +4923,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.vue": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.8.0.tgz",
|
||||
"integrity": "sha512-+XKbSKvQu158zlHsJm+HObGUQM3Q+9Oq6yb2op/6lMM2gwOiiG9uIhujvrSV5UoTjrzR0BcNxCdW2kk7KE4NEg==",
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.8.1.tgz",
|
||||
"integrity": "sha512-kJORXcI5Uml5GtzcfFkchpljFgaXKQB/3ZUt+Hwnw7Y1F7FiA01U2yyO5Vscwv3bVxPkm/KLKXUOXsHbTsYrTw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
@@ -5094,9 +5048,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.1.tgz",
|
||||
"integrity": "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5110,31 +5064,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.1",
|
||||
"@rollup/rollup-android-arm64": "4.59.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.1",
|
||||
"@rollup/rollup-darwin-x64": "4.59.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.1",
|
||||
"@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",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -5195,12 +5149,6 @@
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/select": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
|
||||
"integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -5514,12 +5462,6 @@
|
||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -5542,14 +5484,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -5700,9 +5642,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -5792,20 +5734,10 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6115,16 +6047,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
|
||||
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
"@vue/runtime-dom": "3.5.30",
|
||||
"@vue/server-renderer": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
"@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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"@onlyoffice/document-editor-vue": "^1.4.0",
|
||||
"ace-builds": "^1.24.2",
|
||||
"axios": "^1.13.5",
|
||||
"clipboard": "^2.0.4",
|
||||
"css-vars-ponyfill": "^2.4.3",
|
||||
"dompurify": "^3.2.4",
|
||||
"epubjs": "^0.3.93",
|
||||
@@ -44,7 +43,8 @@
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.1"
|
||||
"minimatch": "^10.2.1",
|
||||
"@xmldom/xmldom": "^0.8.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
@@ -53,7 +53,7 @@
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"deepl-node": "^1.18.0",
|
||||
"deepl-node": "^1.16.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-vue": "^9.24.0",
|
||||
|
||||
@@ -44,7 +44,8 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
/* Include every spec under testDir (prompts.spec.ts, settings-screenshots, etc.) */
|
||||
testMatch: /\.spec\.ts$/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
@@ -53,7 +54,7 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
testMatch: /\.spec\.ts$/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -43,6 +43,7 @@ server:
|
||||
ignoreSymlinks: false # Excludes symbolic links
|
||||
defaultUserScope: "/" # defaults to root of index "/" should match folders under path
|
||||
defaultEnabled: true # should be added as a default source for new users?
|
||||
createUserDir: false # create a user directory for each user under defaultUserScope + username
|
||||
useLogicalSize: false # calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
|
||||
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
@@ -115,7 +116,7 @@ auth:
|
||||
disableVerifyTLS: false # disable TLS verification (insecure, for testing only)
|
||||
logoutRedirectUrl: "" # if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
header: "" # HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
|
||||
secret: "" # secret: shared secret key for verifying JWT token signatures (required)
|
||||
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"
|
||||
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".
|
||||
|
||||
@@ -347,3 +347,14 @@ body > .loader.done {
|
||||
min-width: 400px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Standard portrait mobile responsive fix */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.loader--placeholder.loader--large .placeholder-item {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
@@ -83,13 +83,19 @@ export async function generateOTP(username, password) {
|
||||
let apiPath = getApiPath('auth/otp/generate', params)
|
||||
const res = await fetch(apiPath, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Password': encodeURIComponent(password),
|
||||
}
|
||||
})
|
||||
return await res.json()
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const msg = body.message || 'Failed to generate OTP'
|
||||
throw new Error(msg)
|
||||
}
|
||||
return body
|
||||
} catch (error) {
|
||||
notify.showError(error || 'Failed to generate OTP')
|
||||
notify.showError(error.message || error || 'Failed to generate OTP')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -101,16 +107,26 @@ export async function verifyOTP(username, password, otp) {
|
||||
let apiPath = getApiPath('auth/otp/verify', params)
|
||||
const res = await fetch(apiPath, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Password': encodeURIComponent(password),
|
||||
'X-Secret': otp,
|
||||
}
|
||||
})
|
||||
if (res.status != 200) {
|
||||
throw new Error('Failed to verify OTP')
|
||||
let msg = 'Failed to verify OTP'
|
||||
try {
|
||||
const errBody = await res.json()
|
||||
if (errBody.message) {
|
||||
msg = errBody.message
|
||||
}
|
||||
} catch {
|
||||
// keep default message
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
} catch (error) {
|
||||
notify.showError(error || 'Failed to verify OTP')
|
||||
notify.showError(error.message || error || 'Failed to verify OTP')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,21 +21,28 @@ export async function getSubtitleContent(source, path, subtitleName, embedded =
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/media/metadata — same directory shape as resources with metadata=true (adjusted to items[]).
|
||||
/** @returns {Promise<object>} directory resource (adjustedData) */
|
||||
export async function fetchDirectoryMediaMetadata(source, path) {
|
||||
const apiPath = getApiPath("media/metadata", { source, path });
|
||||
// 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) */
|
||||
export async function fetchDirectoryMediaMetadata(source, path, albumArt = false) {
|
||||
const apiPath = getApiPath("media/metadata", {
|
||||
source,
|
||||
path,
|
||||
...(albumArt ? { albumArt: "true" } : {}),
|
||||
});
|
||||
const res = await fetchURL(apiPath);
|
||||
const data = await res.json();
|
||||
return adjustedData(data);
|
||||
}
|
||||
|
||||
// GET /public/api/media/metadata
|
||||
/** @returns {Promise<object>} directory resource (adjustedData) */
|
||||
export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "") {
|
||||
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
|
||||
/** @returns {Promise<object>} resource (adjustedData) */
|
||||
export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "", albumArt = false) {
|
||||
const params = {
|
||||
path,
|
||||
hash,
|
||||
...(albumArt ? { albumArt: "true" } : {}),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
};
|
||||
const apiPath = getPublicApiPath("media/metadata", params);
|
||||
|
||||
@@ -462,7 +462,18 @@ export function post(
|
||||
error.response = { status: request.status, responseText: request.responseText };
|
||||
reject(error);
|
||||
} else {
|
||||
reject(new Error(request.responseText || "Upload failed"));
|
||||
let errorMessage = "Upload failed";
|
||||
try {
|
||||
const errorData = JSON.parse(request.responseText);
|
||||
errorMessage = errorData.message || errorMessage;
|
||||
} catch (e) {
|
||||
errorMessage = request.responseText || errorMessage;
|
||||
}
|
||||
const error = new Error(errorMessage);
|
||||
error.status = request.status;
|
||||
error.response = { status: request.status, responseText: request.responseText };
|
||||
notify.showError(errorMessage);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -86,9 +86,9 @@ export async function fileWatcherLatencyCheck() {
|
||||
}
|
||||
|
||||
// GET /api/tools/fileWatcher
|
||||
export async function fileWatcher(source, path) {
|
||||
export async function fileWatcher(source, path, lines) {
|
||||
try {
|
||||
const params = { source, path };
|
||||
const params = { source, path, lines: lines };
|
||||
const apiPath = getApiPath("tools/fileWatcher", params);
|
||||
const res = await fetchURL(apiPath);
|
||||
return await res.json();
|
||||
|
||||
+163
-28
@@ -1,6 +1,9 @@
|
||||
import { fetchURL, fetchJSON } from '@/api/utils'
|
||||
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
|
||||
import { notify } from '@/notify'
|
||||
import { state } from '@/store/state.js'
|
||||
import { mutations } from '@/store/mutations.js'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
// GET /api/users (list all)
|
||||
export async function getAllUsers() {
|
||||
@@ -25,36 +28,82 @@ export async function get(id) {
|
||||
}
|
||||
|
||||
// POST /api/users (create user)
|
||||
export async function create(user) {
|
||||
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
|
||||
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
|
||||
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
|
||||
export async function create(user, options = {}) {
|
||||
const mergedHeaders = { ...(options.headers || {}) }
|
||||
const apiPath = getApiPath('users')
|
||||
const body = JSON.stringify({
|
||||
which: [],
|
||||
data: user
|
||||
})
|
||||
|
||||
const needsActorPasswordRetry = (err) =>
|
||||
state.user?.loginMethod === 'password' &&
|
||||
options.skipActorPasswordConfirm !== true &&
|
||||
mergedHeaders['X-Password'] === undefined &&
|
||||
err &&
|
||||
err.status === 401 &&
|
||||
typeof err.message === 'string' &&
|
||||
err.message.includes('X-Password')
|
||||
|
||||
try {
|
||||
const apiPath = getApiPath('users')
|
||||
const res = await fetchURL(apiPath, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
which: [],
|
||||
data: user
|
||||
})
|
||||
body,
|
||||
headers: mergedHeaders,
|
||||
})
|
||||
|
||||
if (res.status === 201) {
|
||||
return res.headers.get('Location')
|
||||
} else {
|
||||
throw new Error('Failed to create user')
|
||||
}
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error creating user')
|
||||
throw err
|
||||
throw new Error('Failed to create user')
|
||||
} catch (e) {
|
||||
if (!needsActorPasswordRetry(e)) {
|
||||
throw e
|
||||
}
|
||||
const promptKey =
|
||||
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
|
||||
return new Promise((resolve, reject) => {
|
||||
mutations.showPrompt({
|
||||
name: 'password',
|
||||
props: {
|
||||
infoText: i18n.global.t(promptKey),
|
||||
submitLabel: i18n.global.t('general.confirm'),
|
||||
submitCallback: async (actorPassword) => {
|
||||
try {
|
||||
const location = await create(user, {
|
||||
...options,
|
||||
headers: {
|
||||
...mergedHeaders,
|
||||
'X-Password': encodeURIComponent(actorPassword),
|
||||
},
|
||||
skipActorPasswordConfirm: true,
|
||||
})
|
||||
resolve(location)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/users (update user)
|
||||
export async function update(user, which = ['all']) {
|
||||
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
|
||||
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
|
||||
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
|
||||
export async function update(user, which = ['all'], options = {}) {
|
||||
const excludeKeys = ['id', 'name']
|
||||
which = which.filter(item => !excludeKeys.includes(item))
|
||||
if (user.username === 'anonymous') {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const mergedHeaders = { ...(options.headers || {}) }
|
||||
|
||||
let userData = user
|
||||
if (which.length !== 1 || which[0] !== 'all') {
|
||||
userData = {}
|
||||
@@ -66,25 +115,111 @@ export async function update(user, which = ['all']) {
|
||||
}
|
||||
|
||||
const apiPath = getApiPath('users', { id: user.id })
|
||||
await fetchURL(apiPath, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
which: which,
|
||||
data: userData
|
||||
})
|
||||
const body = JSON.stringify({
|
||||
which: which,
|
||||
data: userData
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE /api/users (remove user)
|
||||
export async function remove(id) {
|
||||
const needsActorPasswordRetry = (err) =>
|
||||
state.user?.loginMethod === 'password' &&
|
||||
options.skipActorPasswordConfirm !== true &&
|
||||
mergedHeaders['X-Password'] === undefined &&
|
||||
err &&
|
||||
err.status === 401 &&
|
||||
typeof err.message === 'string' &&
|
||||
err.message.includes('X-Password')
|
||||
|
||||
try {
|
||||
const apiPath = getApiPath('users', { id: id })
|
||||
await fetchURL(apiPath, {
|
||||
method: 'DELETE'
|
||||
method: 'PUT',
|
||||
body,
|
||||
headers: mergedHeaders,
|
||||
})
|
||||
} catch (e) {
|
||||
if (!needsActorPasswordRetry(e)) {
|
||||
throw e
|
||||
}
|
||||
const promptKey =
|
||||
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
|
||||
return new Promise((resolve, reject) => {
|
||||
mutations.showPrompt({
|
||||
name: 'password',
|
||||
props: {
|
||||
infoText: i18n.global.t(promptKey),
|
||||
submitLabel: i18n.global.t('general.confirm'),
|
||||
submitCallback: async (actorPassword) => {
|
||||
try {
|
||||
await update(user, which, {
|
||||
...options,
|
||||
headers: {
|
||||
...mergedHeaders,
|
||||
'X-Password': encodeURIComponent(actorPassword),
|
||||
},
|
||||
skipActorPasswordConfirm: true,
|
||||
})
|
||||
resolve(undefined)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/users (remove user)
|
||||
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
|
||||
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
|
||||
// options.actorPasswordPromptI18nKey — optional vue-i18n key (default: confirmPasswordToSaveUser).
|
||||
export async function remove(id, options = {}) {
|
||||
const mergedHeaders = { ...(options.headers || {}) }
|
||||
const apiPath = getApiPath('users', { id: id })
|
||||
|
||||
const needsActorPasswordRetry = (err) =>
|
||||
state.user?.loginMethod === 'password' &&
|
||||
options.skipActorPasswordConfirm !== true &&
|
||||
mergedHeaders['X-Password'] === undefined &&
|
||||
err &&
|
||||
err.status === 401 &&
|
||||
typeof err.message === 'string' &&
|
||||
err.message.includes('X-Password')
|
||||
|
||||
try {
|
||||
await fetchURL(apiPath, {
|
||||
method: 'DELETE',
|
||||
headers: mergedHeaders,
|
||||
})
|
||||
} catch (e) {
|
||||
if (!needsActorPasswordRetry(e)) {
|
||||
throw e
|
||||
}
|
||||
const promptKey =
|
||||
options.actorPasswordPromptI18nKey || 'prompts.confirmPasswordToSaveUser'
|
||||
return new Promise((resolve, reject) => {
|
||||
mutations.showPrompt({
|
||||
name: 'password',
|
||||
props: {
|
||||
infoText: i18n.global.t(promptKey),
|
||||
submitLabel: i18n.global.t('general.confirm'),
|
||||
submitCallback: async (actorPassword) => {
|
||||
try {
|
||||
await remove(id, {
|
||||
...options,
|
||||
headers: {
|
||||
...mergedHeaders,
|
||||
'X-Password': encodeURIComponent(actorPassword),
|
||||
},
|
||||
skipActorPasswordConfirm: true,
|
||||
})
|
||||
resolve(undefined)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
notify.showError(err.message || `Failed to delete user with ID: ${id}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
:title="label"
|
||||
class="action no-select"
|
||||
>
|
||||
<i v-if="icon == 'table_rows_narrow' || icon == 'content_copy'" class="material-symbols-outlined">{{ icon }}</i>
|
||||
<i v-if="icon == 'table_rows_narrow' || icon == 'content_copy' || icon == 'file_copy' || icon == 'copy_all'" class="material-symbols-outlined">{{ icon }}</i>
|
||||
<i v-else class="material-symbols">{{ icon }}</i>
|
||||
|
||||
<span>{{ label }}</span>
|
||||
|
||||
@@ -10,15 +10,27 @@
|
||||
ref="contextMenu"
|
||||
v-if="showContext"
|
||||
:style="centered ? {} : { top: posY + 'px', left: posX + 'px' }"
|
||||
class="button no-select floating-window"
|
||||
class="no-select floating-window"
|
||||
:class="{ 'dark-mode': isDarkMode, 'centered': centered }"
|
||||
:key="showCreate ? 'create-mode' : 'normal-mode'"
|
||||
>
|
||||
<div v-if="!showLimitedOptions" class="context-menu-header">
|
||||
<div
|
||||
v-if="showCreateToggle"
|
||||
class="context-menu-header"
|
||||
>
|
||||
<div
|
||||
class="action button clickable"
|
||||
v-if="showCreateButton"
|
||||
@click="toggleShowCreate"
|
||||
v-if="showCreateToggle"
|
||||
class="action button clickable context-menu-create-toggle"
|
||||
aria-label="Create Actions Toggle"
|
||||
:class="{ 'context-menu-create-toggle--disabled': createToggleDisabled }"
|
||||
role="button"
|
||||
:aria-disabled="createToggleDisabled ? 'true' : 'false'"
|
||||
:tabindex="createToggleDisabled ? -1 : 0"
|
||||
@click="onCreateToggleClick"
|
||||
@keydown.enter.prevent="onCreateToggleClick"
|
||||
@keydown.space.prevent="onCreateToggleClick"
|
||||
@mouseenter="onCreateToggleMouseEnter"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<i v-if="!showCreate" class="material-symbols">add</i>
|
||||
<i v-else class="material-symbols">arrow_back</i>
|
||||
@@ -28,29 +40,36 @@
|
||||
@mouseleave="hideTooltip"
|
||||
@mouseenter="showTooltip($event, $t('buttons.selectedCount'))"
|
||||
class="button selected-count-header"
|
||||
:class="{ 'selected-count-header--circle': selectedCount <= 99 }"
|
||||
>
|
||||
<span>{{ selectedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="showDivider" class="divider">
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreateFileActions"
|
||||
icon="create_new_folder"
|
||||
:label="$t('files.newFolder')"
|
||||
@action="showNewDirPrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreateFileActions"
|
||||
icon="note_add"
|
||||
:label="$t('files.newFile')"
|
||||
@action="showPrompt('newFile')"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreateFileActions"
|
||||
icon="file_upload"
|
||||
:label="$t('general.upload')"
|
||||
@action="showUploadPrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showArchive"
|
||||
icon="archive"
|
||||
:label="$t('prompts.archive')"
|
||||
@action="showArchivePrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showInfo"
|
||||
icon="info"
|
||||
@@ -64,12 +83,6 @@
|
||||
:label="$t('general.download')"
|
||||
@action="startDownload"
|
||||
/>
|
||||
<action
|
||||
v-if="showArchive"
|
||||
icon="archive"
|
||||
:label="$t('prompts.archive')"
|
||||
@action="showArchivePrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showUnarchive"
|
||||
icon="unarchive"
|
||||
@@ -90,10 +103,16 @@
|
||||
/>
|
||||
<action
|
||||
v-if="showCopy"
|
||||
icon="content_copy"
|
||||
icon="file_copy"
|
||||
:label="$t('buttons.copyFile')"
|
||||
@action="showCopyPrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showCopyPath"
|
||||
icon="copy_all"
|
||||
:label="$t('buttons.copyPath')"
|
||||
@action="copyPathToClipboard"
|
||||
/>
|
||||
<action
|
||||
v-if="showOpenParentFolder"
|
||||
icon="folder"
|
||||
@@ -152,7 +171,7 @@
|
||||
top: '3em',
|
||||
right: '1em',
|
||||
}"
|
||||
class="button no-select floating-window"
|
||||
class="no-select floating-window"
|
||||
:class="{ 'dark-mode': isDarkMode }"
|
||||
>
|
||||
<action icon="info" :label="$t('general.info')" @action="showInfoPrompt"/>
|
||||
@@ -179,6 +198,7 @@ import buttons from "@/utils/buttons";
|
||||
import { notify } from "@/notify";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { url } from "@/utils";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
|
||||
function isArchivePath(pathOrName) {
|
||||
if (!pathOrName || typeof pathOrName !== "string") return false;
|
||||
@@ -221,7 +241,10 @@ export default {
|
||||
computed: {
|
||||
// Either from prop or from state
|
||||
providedItems() {
|
||||
if (this.items) return this.items;
|
||||
// Only treat non-empty explicit `items` as overriding selection ([] means "use global state")
|
||||
if (Array.isArray(this.items) && this.items.length > 0) {
|
||||
return this.items;
|
||||
}
|
||||
// Fallback to global selection (indices or objects)
|
||||
if (state.selected.length === 0) return [];
|
||||
// Map to actual items from state.req
|
||||
@@ -245,39 +268,60 @@ export default {
|
||||
permissions() {
|
||||
return getters.permissions();
|
||||
},
|
||||
/**
|
||||
* Whether the (+) panel can be opened (create file ops and/or admin-only rows like access rules).
|
||||
*/
|
||||
canOpenCreatePanel() {
|
||||
if (getters.isShare()) {
|
||||
return !!getters.permissions().create;
|
||||
}
|
||||
return getters.isAdmin() || !!state.user?.permissions?.create;
|
||||
},
|
||||
/** New folder / new file / upload — requires permissions.create only (not admin alone). */
|
||||
showCreateFileActions() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
if (!this.permissions.create) return false;
|
||||
return this.showCreate && !this.isSearchActive;
|
||||
},
|
||||
req() {
|
||||
return state.req;
|
||||
},
|
||||
isShare() {
|
||||
return getters.isShare();
|
||||
},
|
||||
showCreateActions() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return this.showCreate && !this.isSearchActive;
|
||||
},
|
||||
showInfo() {
|
||||
if (this.showLimitedOptions) return this.selectedCount == 1;
|
||||
return !this.showCreate && this.selectedCount == 1;
|
||||
},
|
||||
showDownload() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return !this.showCreate && this.permissions.download && this.selectedCount > 0;
|
||||
return (
|
||||
!this.showCreate &&
|
||||
!!this.permissions.download &&
|
||||
this.selectedCount > 0
|
||||
);
|
||||
},
|
||||
showArchive() {
|
||||
if (this.showLimitedOptions || getters.isShare()) return false;
|
||||
if (!this.permissions.create) return false;
|
||||
return !this.showCreate && this.selectedCount > 0 && !this.showUnarchive;
|
||||
return (
|
||||
this.showCreate &&
|
||||
this.selectedCount > 0 &&
|
||||
!this.showUnarchive &&
|
||||
!this.isSearchActive
|
||||
);
|
||||
},
|
||||
showUnarchive() {
|
||||
if (this.showLimitedOptions || getters.isShare()) return false;
|
||||
if (!this.permissions.create) return false;
|
||||
if (this.showCreate) return false;
|
||||
if (this.selectedCount !== 1) return false;
|
||||
const item = this.firstSelected;
|
||||
return item && isArchivePath(item.path || item.from || item.name);
|
||||
},
|
||||
showShareAction() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return (this.showCreate || this.selectedCount == 1) && this.showShare;
|
||||
return this.selectedCount <= 1 && this.showShare;
|
||||
},
|
||||
showRename() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
@@ -287,6 +331,10 @@ export default {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return !this.showCreate && this.selectedCount > 0 && this.permissions.modify;
|
||||
},
|
||||
showCopyPath() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return !this.showCreate && this.selectedCount == 1 && !!state.user?.showCopyPath;
|
||||
},
|
||||
showOpenParentFolder() {
|
||||
return !this.showCreate && this.selectedCount == 1 && (this.isSearchActive || this.showLimitedOptions);
|
||||
},
|
||||
@@ -298,16 +346,17 @@ export default {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return !this.showCreate && !this.isSearchActive && this.req?.items?.length > 0;
|
||||
},
|
||||
showCreateButton() {
|
||||
/** + control in listing / share directory context; disabled when the panel cannot be opened. */
|
||||
showCreateToggle() {
|
||||
if (this.showLimitedOptions || this.createOnly) return false;
|
||||
return !this.isSearchActive && this.permissions.create && !this.isShare;
|
||||
return !this.isSearchActive;
|
||||
},
|
||||
createToggleDisabled() {
|
||||
return !this.canOpenCreatePanel;
|
||||
},
|
||||
showDivider() {
|
||||
if (this.showLimitedOptions || this.createOnly) return false;
|
||||
if (getters.isShare()) {
|
||||
return state.shareInfo?.allowCreate
|
||||
}
|
||||
return state.user?.permissions?.create || state.user?.permissions?.share || state.user?.permissions?.admin;
|
||||
return this.showCreateToggle || this.selectedCount > 0;
|
||||
},
|
||||
showSelectMultiple() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
@@ -347,11 +396,14 @@ export default {
|
||||
return false;
|
||||
}
|
||||
const cv = getters.currentView();
|
||||
const showDelete = cv != "settings" && !this.isSearchActive && this.permissions.delete;
|
||||
return showDelete;
|
||||
return (
|
||||
cv != "settings" &&
|
||||
!this.isSearchActive &&
|
||||
!!this.permissions.delete
|
||||
);
|
||||
},
|
||||
hasDownload() {
|
||||
return this.selectedCount > 0 && this.permissions.download;
|
||||
return this.selectedCount > 0 && !!this.permissions.download;
|
||||
},
|
||||
isPreview() {
|
||||
return getters.isPreviewView();
|
||||
@@ -368,7 +420,10 @@ export default {
|
||||
if (getters.isShare()) {
|
||||
return false;
|
||||
}
|
||||
return this.permissions.admin && this.showCreate;
|
||||
if (this.selectedCount > 1) {
|
||||
return false;
|
||||
}
|
||||
return getters.isAdmin() && this.showCreate;
|
||||
},
|
||||
showShare() {
|
||||
if (getters.isShare()) {
|
||||
@@ -464,8 +519,16 @@ export default {
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
onCreateToggleClick() {
|
||||
if (this.createToggleDisabled) return;
|
||||
this.toggleShowCreate();
|
||||
},
|
||||
onCreateToggleMouseEnter(event) {
|
||||
if (!this.createToggleDisabled) return;
|
||||
this.showTooltip(event, this.$t("messages.noCreateOptions"));
|
||||
},
|
||||
toggleShowCreate() {
|
||||
if (!this.permissions.create) {
|
||||
if (!this.canOpenCreatePanel) {
|
||||
this.showCreate = false;
|
||||
return;
|
||||
}
|
||||
@@ -476,11 +539,9 @@ export default {
|
||||
},
|
||||
showAccessPrompt() {
|
||||
mutations.closeHovers();
|
||||
let sourceName = this.firstSelected?.source || state.req.source;
|
||||
let path = this.firstSelected?.path || state.req.path;
|
||||
if (this.firstSelected && !this.firstSelected.isDir) {
|
||||
path = url.removeLastDir(path) + '/';
|
||||
}
|
||||
const item = this.firstSelected;
|
||||
let sourceName = item?.source || state.req.source;
|
||||
let path = item?.path || state.req.path;
|
||||
mutations.showPrompt({
|
||||
name: "access",
|
||||
props: {
|
||||
@@ -552,7 +613,7 @@ export default {
|
||||
}, 300);
|
||||
},
|
||||
startShowCreate() {
|
||||
if (!this.permissions.create) {
|
||||
if (!this.canOpenCreatePanel) {
|
||||
return;
|
||||
}
|
||||
this.showCreate = true;
|
||||
@@ -597,17 +658,12 @@ export default {
|
||||
this.posY = contextProps.posY;
|
||||
},
|
||||
initializeCreateState() {
|
||||
// If createOnly is set, always show create actions
|
||||
if (this.createOnly) {
|
||||
this.showCreate = true;
|
||||
return;
|
||||
}
|
||||
// Only set initial showCreate state, don't override user choices
|
||||
if (this.selectedCount > 0 || !this.permissions.create) {
|
||||
this.showCreate = false;
|
||||
} else {
|
||||
this.showCreate = true;
|
||||
}
|
||||
// Right-click / prompt: start in normal mode; user opens create via + or sidebar (createOnly).
|
||||
this.showCreate = false;
|
||||
},
|
||||
toggleMultipleSelection() {
|
||||
mutations.setMultiple(true);
|
||||
@@ -647,6 +703,13 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
async copyPathToClipboard() {
|
||||
const item = this.firstSelected;
|
||||
const path = item?.path || "";
|
||||
|
||||
await copyToClipboard(path);
|
||||
mutations.closeHovers();
|
||||
},
|
||||
showNewDirPrompt() {
|
||||
mutations.closeHovers();
|
||||
// If the context menu was triggered on a directory, pass its path as base
|
||||
@@ -792,12 +855,14 @@ export default {
|
||||
z-index: 1000;
|
||||
background-color: var(--background);
|
||||
max-width: 20em;
|
||||
min-width: 15em;
|
||||
min-width: 13em;
|
||||
min-height: 4em;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-radius: 1em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
#context-menu.centered {
|
||||
@@ -812,10 +877,27 @@ export default {
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
/* Circle for up to 3 digits; 1000+ keeps pill shape from .button */
|
||||
.selected-count-header--circle {
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
min-width: 2em;
|
||||
min-height: 2em;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.context-menu-header > .action i {
|
||||
padding: 0.25em;
|
||||
}
|
||||
|
||||
.context-menu-create-toggle--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#context-menu .action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -855,5 +937,7 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
</style>
|
||||
@@ -69,7 +69,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loading icon when search is ongoing -->
|
||||
<LoadingSpinner v-if="ongoing" size="medium" />
|
||||
<LoadingSpinner v-if="isRunning" size="medium" />
|
||||
<!-- Message when no results are found -->
|
||||
<div class="searchPrompt" v-show="isEmpty && !isRunning">
|
||||
<p>{{ noneMessage }}</p>
|
||||
@@ -145,14 +145,14 @@ export default {
|
||||
],
|
||||
typeSelect: [
|
||||
{ label: this.$t("general.photos"), value: "type:image" },
|
||||
{ label: this.$t("general.audios"), value: "type:audio" },
|
||||
{ label: this.$t("general.audio"), value: "type:audio" },
|
||||
{ label: this.$t("general.videos"), value: "type:video" },
|
||||
{ label: this.$t("general.documents"), value: "type:doc" },
|
||||
{ label: this.$t("general.archives"), value: "type:archive" },
|
||||
],
|
||||
toggleOptionButton: [{ label: this.$t("search.showOptions") }],
|
||||
value: "",
|
||||
ongoing: false,
|
||||
ongoing: 0,
|
||||
results: [],
|
||||
reload: false,
|
||||
scrollable: null,
|
||||
@@ -181,7 +181,7 @@ export default {
|
||||
},
|
||||
value() {
|
||||
if (this.results.length) {
|
||||
this.ongoing = false;
|
||||
this.ongoing = 0;
|
||||
this.results = [];
|
||||
}
|
||||
},
|
||||
@@ -207,7 +207,7 @@ export default {
|
||||
|
||||
// Add keyboard event listener for "/" to activate search
|
||||
this.handleKeydown = (event) => {
|
||||
if (event.key === '/' && !state.isSearchActive && getters.currentPrompt() === null) {
|
||||
if (event.key === '/' || event.key === ' ' && !state.isSearchActive && getters.currentPrompt() === null) {
|
||||
event.preventDefault();
|
||||
this.open();
|
||||
}
|
||||
@@ -267,17 +267,16 @@ export default {
|
||||
return this.results.length === 0;
|
||||
},
|
||||
text() {
|
||||
if (this.ongoing) {
|
||||
if (this.ongoing > 0) {
|
||||
return "";
|
||||
}
|
||||
return this.$t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength })
|
||||
},
|
||||
isRunning() {
|
||||
return this.ongoing;
|
||||
return this.ongoing > 0;
|
||||
},
|
||||
activeStates() {
|
||||
const selectedItems = state.selected ? [].concat(state.selected) : [];
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
// Return an array of all false if nothing is selected
|
||||
return new Array(this.results.length).fill(false);
|
||||
@@ -464,12 +463,11 @@ export default {
|
||||
},
|
||||
async submit(event) {
|
||||
this.results = [];
|
||||
|
||||
if (event != undefined) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (this.value === "" || this.value.length < globalVars.minSearchLength) {
|
||||
this.ongoing = false; this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: globalVars.minSearchLength });
|
||||
this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: globalVars.minSearchLength });
|
||||
return;
|
||||
}
|
||||
let searchTypesFull = this.searchTypes;
|
||||
@@ -488,7 +486,7 @@ export default {
|
||||
if (newerUnix !== null) {
|
||||
dateParams.newerThan = newerUnix;
|
||||
}
|
||||
this.ongoing = true;
|
||||
this.ongoing++;
|
||||
|
||||
// Determine which sources to search
|
||||
let sourcesToSearch = [];
|
||||
@@ -511,13 +509,13 @@ export default {
|
||||
dateParams
|
||||
);
|
||||
|
||||
this.ongoing = false;
|
||||
if (this.results.length == 0) {
|
||||
this.ongoing--;
|
||||
if (this.results.length == 0 && this.ongoing == 0) {
|
||||
this.noneMessage = this.$t("search.noResults");
|
||||
}
|
||||
},
|
||||
showHelpTooltip(event) {
|
||||
const helpText = `${this.$t("search.helpText1")}\n\n${this.$t("search.helpText2")}`;
|
||||
const helpText = `${this.$t("search.helpText1", { minSearchLength: globalVars.minSearchLength })}\n\n${this.$t("search.helpText2")}`;
|
||||
mutations.showTooltip({
|
||||
content: helpText,
|
||||
x: event.clientX,
|
||||
@@ -548,6 +546,26 @@ export default {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
open() {
|
||||
if (state.isSearchActive) return;
|
||||
if (getters.currentPromptName()) return;
|
||||
|
||||
mutations.closeHovers();
|
||||
mutations.closeSidebar();
|
||||
mutations.resetSelected();
|
||||
mutations.setSearch(true);
|
||||
|
||||
this.$nextTick(() => {
|
||||
const input = this.$refs.input;
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
const resultList = document.getElementById("result-list");
|
||||
if (resultList) {
|
||||
resultList.classList.add("active");
|
||||
}
|
||||
});
|
||||
},
|
||||
addSelected(event, s) {
|
||||
event.preventDefault();
|
||||
const pathParts = url.removeTrailingSlash(s.path).split("/");
|
||||
|
||||
@@ -202,7 +202,7 @@ export default {
|
||||
},
|
||||
// Ctrl + Mouse Wheel to adjust the slider sizes
|
||||
handleWheel(event) {
|
||||
if (!event.ctrlKey) return;
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
|
||||
const delta = event.deltaY > 0 ? 1 : -1; // Scroll down increases, up decreases
|
||||
|
||||
|
||||
@@ -223,7 +223,8 @@ export default {
|
||||
fromSource: selectedItem.source,
|
||||
to: url.joinPath(targetPath, selectedItem.name),
|
||||
toSource: source,
|
||||
itemType: selectedItem.type
|
||||
itemType: selectedItem.type,
|
||||
name: selectedItem.name
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,7 +256,7 @@ export default {
|
||||
targetDirItems = response?.items;
|
||||
}
|
||||
} catch (error) {
|
||||
notify.showErrorToast(this.$t("files.cannotAccesDir"));
|
||||
notify.showError(this.$t("files.cannotAccesDir"));
|
||||
console.log("Cannot access to target directory", e);
|
||||
return;
|
||||
}
|
||||
@@ -295,7 +296,7 @@ export default {
|
||||
mutations.setReload(true);
|
||||
} catch (error) {
|
||||
mutations.closeTopPrompt();
|
||||
notify.showErrorToast(this.$t("prompts.moveFailed"));
|
||||
notify.showError(this.$t("prompts.moveFailed"));
|
||||
console.log("Move failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -547,6 +547,7 @@ export default {
|
||||
this.applyImgTransform();
|
||||
},
|
||||
mousedownStart(event) {
|
||||
if (event.button !== 0) return;
|
||||
if (this.scale === 1) {
|
||||
this.teardownEdgeMouseListeners();
|
||||
this.edgeMouseActive = true;
|
||||
@@ -567,12 +568,14 @@ export default {
|
||||
event.preventDefault();
|
||||
},
|
||||
mouseMove(event) {
|
||||
if (event.button !== 0) return;
|
||||
if (this.scale > 1 && this.inDrag) {
|
||||
this.doMove(event.movementX, event.movementY);
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
mouseUp(event) {
|
||||
if (event.button !== 0) return;
|
||||
if (this.scale > 1) {
|
||||
this.inDrag = false;
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="card-content" aria-label="file-list-prompt">
|
||||
<!-- Source Selection Dropdown -->
|
||||
<!-- 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;">
|
||||
<label for="destinationSource" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
{{ $t("prompts.destinationSource") }}
|
||||
@@ -96,6 +96,11 @@ export default {
|
||||
type: String,
|
||||
default: null, // Optional initial path to start browsing from
|
||||
},
|
||||
/** When true, never show the destination source dropdown (source is fixed via browseSource). */
|
||||
hideDestinationSource: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
requireFileSelection: {
|
||||
type: Boolean,
|
||||
default: false, // If true, only files (not folders) can be selected
|
||||
@@ -138,6 +143,9 @@ export default {
|
||||
return state.sources && state.sources.info ? Object.keys(state.sources.info) : [state.req.source];
|
||||
},
|
||||
showSourceSelector() {
|
||||
if (this.hideDestinationSource) {
|
||||
return false;
|
||||
}
|
||||
return this.availableSources.length > 1 && !this.fileList && !getters.isShare() && !this.browseShare;
|
||||
},
|
||||
isValidSelection() {
|
||||
@@ -449,6 +457,7 @@ export default {
|
||||
let clickedItem = this.items.find(item => item.path === path);
|
||||
this.selectedSource = clickedItem ? clickedItem.source : this.source;
|
||||
this.selectedType = clickedItem ? clickedItem.type : null;
|
||||
const isFile = clickedItem && clickedItem.type !== "directory";
|
||||
this.$emit("update:selected", {
|
||||
path: this.selected,
|
||||
source: this.selectedSource,
|
||||
|
||||
@@ -72,10 +72,6 @@
|
||||
:clickable=true
|
||||
:isShared="isShared"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="quickDownloadPlaceholder"
|
||||
class="placeholder"
|
||||
/>
|
||||
</a>
|
||||
<div
|
||||
v-else
|
||||
@@ -222,10 +218,10 @@ export default {
|
||||
// @ts-ignore
|
||||
if (getters.isShare()) {
|
||||
// @ts-ignore
|
||||
return state.shareInfo?.quickDownload && !this.galleryView && !this.isDir;
|
||||
return state.shareInfo?.quickDownload && !this.galleryView;
|
||||
}
|
||||
// @ts-ignore
|
||||
return state.user?.quickDownload && !this.galleryView && !this.isDir;
|
||||
return state.user?.quickDownload && !this.galleryView;
|
||||
},
|
||||
quickDownloadPlaceholder() {
|
||||
// @ts-ignore
|
||||
@@ -554,13 +550,11 @@ export default {
|
||||
let items = [];
|
||||
for (let i of state.selected) {
|
||||
items.push({
|
||||
// @ts-ignore
|
||||
from: state.req.items[i].path,
|
||||
// @ts-ignore
|
||||
fromSource: state.req.items[i].source,
|
||||
// @ts-ignore
|
||||
to: url.joinPath(this.path, state.req.items[i].name),
|
||||
toSource: this.source,
|
||||
name: state.req.items[i].name,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ import throttle from "@/utils/throttle";
|
||||
import { previewViews } from "@/utils/constants";
|
||||
import { url } from "@/utils";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { replaceRouteForPlaybackQueueStep } from "@/utils/previewPlaybackQueueNav.js";
|
||||
|
||||
export default {
|
||||
name: "NextPrevious",
|
||||
@@ -187,13 +188,13 @@ export default {
|
||||
},
|
||||
hasPrevious() {
|
||||
if (this.isMediaQueueMode) {
|
||||
return this.hasMediaPrevious();
|
||||
return getters.playbackQueueCanGoPrevious();
|
||||
}
|
||||
return state.navigation.previousLink !== "";
|
||||
},
|
||||
hasNext() {
|
||||
if (this.isMediaQueueMode) {
|
||||
return this.hasMediaNext();
|
||||
return getters.playbackQueueCanGoNext();
|
||||
}
|
||||
return state.navigation.nextLink !== "";
|
||||
},
|
||||
@@ -212,14 +213,7 @@ export default {
|
||||
return previewType === 'audio' || previewType === 'video';
|
||||
},
|
||||
isMediaQueueMode() {
|
||||
const previewType = getters.previewType();
|
||||
const isMediaView = previewType === 'audio' || previewType === 'video';
|
||||
const mode = state.playbackQueue?.mode || 'single';
|
||||
const queueLength = state.playbackQueue?.queue?.length || 0;
|
||||
const hasQueue = queueLength > 1;
|
||||
|
||||
// Use media queue when in media view, NOT in single/loop-single mode, and have a queue
|
||||
return isMediaView && mode !== 'single' && mode !== 'loop-single' && hasQueue;
|
||||
return getters.isPreviewPlaybackQueueNavMode();
|
||||
},
|
||||
leftZoneStyle() {
|
||||
const styles = {
|
||||
@@ -531,7 +525,7 @@ export default {
|
||||
// Editor and other components check isTransitioning to prevent saves
|
||||
mutations.setNavigationTransitioning(true);
|
||||
if (this.isMediaQueueMode) {
|
||||
this.navigateMediaPrevious();
|
||||
replaceRouteForPlaybackQueueStep(this.$router, -1);
|
||||
} else {
|
||||
this.$router.replace({ path: state.navigation.previousLink });
|
||||
}
|
||||
@@ -551,120 +545,12 @@ export default {
|
||||
mutations.setNavigationTransitioning(true);
|
||||
|
||||
if (this.isMediaQueueMode) {
|
||||
this.navigateMediaNext();
|
||||
replaceRouteForPlaybackQueueStep(this.$router, 1);
|
||||
} else {
|
||||
this.$router.replace({ path: state.navigation.nextLink });
|
||||
}
|
||||
}
|
||||
},
|
||||
hasMediaPrevious() {
|
||||
const queue = state.playbackQueue?.queue || [];
|
||||
const currentIndex = state.playbackQueue?.currentIndex ?? -1;
|
||||
const mode = state.playbackQueue?.mode || 'single';
|
||||
|
||||
if (queue.length <= 1 || currentIndex < 0) return false;
|
||||
|
||||
// For sequential mode, no previous if at start
|
||||
if (mode === 'sequential' && currentIndex === 0) return false;
|
||||
|
||||
// For loop-all and shuffle, always have previous (wraps around)
|
||||
return true;
|
||||
},
|
||||
hasMediaNext() {
|
||||
const queue = state.playbackQueue?.queue || [];
|
||||
const currentIndex = state.playbackQueue?.currentIndex ?? -1;
|
||||
const mode = state.playbackQueue?.mode || 'single';
|
||||
|
||||
if (queue.length <= 1 || currentIndex < 0) return false;
|
||||
|
||||
// For sequential mode, no next if at end
|
||||
if (mode === 'sequential' && currentIndex >= queue.length - 1) return false;
|
||||
|
||||
// For loop-all and shuffle, always have next (wraps around)
|
||||
return true;
|
||||
},
|
||||
navigateMediaPrevious() {
|
||||
const queue = state.playbackQueue?.queue || [];
|
||||
const currentIndex = state.playbackQueue?.currentIndex ?? -1;
|
||||
const mode = state.playbackQueue?.mode || 'single';
|
||||
|
||||
if (queue.length === 0 || currentIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prevIndex = currentIndex - 1;
|
||||
|
||||
// Handle wrapping
|
||||
if (prevIndex < 0) {
|
||||
if (mode === 'loop-all' || mode === 'shuffle') {
|
||||
prevIndex = queue.length - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const prevItem = queue[prevIndex];
|
||||
if (!prevItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update queue index
|
||||
mutations.setPlaybackQueue({
|
||||
queue: queue,
|
||||
currentIndex: prevIndex,
|
||||
mode: mode
|
||||
});
|
||||
|
||||
// Navigate
|
||||
const prevItemUrl = url.buildItemUrl(prevItem.source || state.req.source, prevItem.path);
|
||||
mutations.replaceRequest(prevItem);
|
||||
this.$router.replace({ path: prevItemUrl }).catch(err => {
|
||||
if (err.name !== 'NavigationDuplicated') {
|
||||
// Silently ignore navigation errors
|
||||
}
|
||||
});
|
||||
},
|
||||
navigateMediaNext() {
|
||||
const queue = state.playbackQueue?.queue || [];
|
||||
const currentIndex = state.playbackQueue?.currentIndex ?? -1;
|
||||
const mode = state.playbackQueue?.mode || 'single';
|
||||
|
||||
if (queue.length === 0 || currentIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextIndex = currentIndex + 1;
|
||||
|
||||
// Handle wrapping
|
||||
if (nextIndex >= queue.length) {
|
||||
if (mode === 'loop-all' || mode === 'shuffle') {
|
||||
nextIndex = 0;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextItem = queue[nextIndex];
|
||||
if (!nextItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update queue index
|
||||
mutations.setPlaybackQueue({
|
||||
queue: queue,
|
||||
currentIndex: nextIndex,
|
||||
mode: mode
|
||||
});
|
||||
|
||||
// Navigate
|
||||
const nextItemUrl = url.buildItemUrl(nextItem.source || state.req.source, nextItem.path);
|
||||
mutations.replaceRequest(nextItem);
|
||||
this.$router.replace({ path: nextItemUrl }).catch(err => {
|
||||
if (err.name !== 'NavigationDuplicated') {
|
||||
// Silently ignore navigation errors
|
||||
}
|
||||
});
|
||||
},
|
||||
keyEvent(event) {
|
||||
// Only handle navigation if enabled and no prompt is active
|
||||
if (!this.enabled || state.prompts.length > 0) {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<!-- API Token Section -->
|
||||
<div class="api-section">
|
||||
<button
|
||||
class="action copy-clipboard api-key-button"
|
||||
:data-clipboard-text="name"
|
||||
class="action api-key-button"
|
||||
@click.stop="copyToClipboard(name)"
|
||||
:aria-label="$t('buttons.copyToClipboard')"
|
||||
:title="$t('buttons.copyToClipboard')"
|
||||
>
|
||||
@@ -12,8 +12,8 @@
|
||||
<i class="material-symbols">content_paste</i>
|
||||
</button>
|
||||
<button
|
||||
class="action copy-clipboard api-key-value-button"
|
||||
:data-clipboard-text="info.token"
|
||||
class="action api-key-value-button"
|
||||
@click.stop="copyToClipboard(info.token)"
|
||||
:aria-label="$t('api.clickToCopyKey')"
|
||||
:title="$t('api.clickToCopyKey')"
|
||||
>
|
||||
@@ -73,6 +73,7 @@ import { mutations } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
import { authApi } from "@/api";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
|
||||
export default {
|
||||
name: "ActionApi",
|
||||
@@ -93,6 +94,9 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async copyToClipboard(text) {
|
||||
await copyToClipboard(text);
|
||||
},
|
||||
formatTime(timestamp) {
|
||||
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
|
||||
@@ -81,9 +81,12 @@ export default {
|
||||
closeTopPrompt() {
|
||||
mutations.closeTopPrompt();
|
||||
},
|
||||
confirm() {
|
||||
this.onConfirm();
|
||||
mutations.closeTopPrompt();
|
||||
async confirm() {
|
||||
try {
|
||||
await this.onConfirm();
|
||||
} finally {
|
||||
mutations.closeTopPrompt();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<script>
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { formatTimestamp } from "@/utils/moment";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import { resourcesApi } from "@/api";
|
||||
import { state } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
@@ -180,26 +181,7 @@ export default {
|
||||
},
|
||||
async copyToClipboard() {
|
||||
if (!this.hashResult) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.hashResult);
|
||||
notify.showSuccessToast(this.$t("prompts.hashCopied"));
|
||||
} catch (err) {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = this.hashResult;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
notify.showSuccessToast(this.$t("prompts.hashCopied"));
|
||||
} catch (e) {
|
||||
notify.showError(this.$t("prompts.errorCopyingHash"));
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
await copyToClipboard(this.hashResult);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -78,6 +78,16 @@ export default {
|
||||
source: state.req?.source || null,
|
||||
};
|
||||
},
|
||||
currentPromptName() {
|
||||
return getters.currentPromptName();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentPromptName(newName, oldName) {
|
||||
if (this.creating && oldName === "replace-rename" && newName !== "new-dir") {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeTopPrompt() {
|
||||
@@ -151,13 +161,15 @@ export default {
|
||||
if (getters.isShare()) {
|
||||
await resourcesApi.postPublic(state.shareInfo?.hash, newPath, "", false, undefined, {}, true);
|
||||
mutations.setReload(true);
|
||||
mutations.closeTopPrompt();
|
||||
mutations.closeTopPrompt(); // close conflict prompt
|
||||
mutations.closeTopPrompt(); // close new dir prompt
|
||||
success = true;
|
||||
return;
|
||||
}
|
||||
await resourcesApi.post(source, newPath, "", false, undefined, {}, true);
|
||||
mutations.setReload(true);
|
||||
mutations.closeTopPrompt();
|
||||
mutations.closeTopPrompt(); // close conflict prompt
|
||||
mutations.closeTopPrompt(); // close new dir prompt
|
||||
success = true;
|
||||
|
||||
// Show success notification with "go to item" button
|
||||
|
||||
@@ -78,6 +78,16 @@ export default {
|
||||
source: state.req?.source || null,
|
||||
};
|
||||
},
|
||||
currentPromptName() {
|
||||
return getters.currentPromptName();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentPromptName(newName, oldName) {
|
||||
if (this.creating && oldName === "replace-rename" && newName !== "new-file") {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async submit(event) {
|
||||
@@ -148,13 +158,15 @@ export default {
|
||||
if (getters.isShare()) {
|
||||
await resourcesApi.postPublic(state.shareInfo?.hash, newPath, "", false);
|
||||
mutations.setReload(true);
|
||||
mutations.closeTopPrompt();
|
||||
mutations.closeTopPrompt(); // close conflict prompt
|
||||
mutations.closeTopPrompt(); // close new file prompt
|
||||
success = true;
|
||||
return;
|
||||
}
|
||||
await resourcesApi.post(source, newPath, "", false);
|
||||
mutations.setReload(true);
|
||||
mutations.closeTopPrompt();
|
||||
mutations.closeTopPrompt(); // close conflict prompt
|
||||
mutations.closeTopPrompt(); // close new file prompt
|
||||
success = true;
|
||||
|
||||
// Show success notification with "go to item" button
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<div v-if="showWrongCredentials" class="form-invalid">
|
||||
{{ $t("login.wrongCredentials") }}
|
||||
</div>
|
||||
<div v-if="infoText">
|
||||
<p>{{ infoText }}</p>
|
||||
</div>
|
||||
<div class="form-flex-group">
|
||||
<input
|
||||
class="input share-password"
|
||||
@@ -10,7 +13,7 @@
|
||||
type="password"
|
||||
:placeholder="$t('general.password')"
|
||||
v-model="password"
|
||||
@keyup.enter="submit"
|
||||
@keyup.enter="canSubmit && submit()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,11 +21,13 @@
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="button button--flat"
|
||||
type="button"
|
||||
@click="submit"
|
||||
:aria-label="$t('general.login')"
|
||||
:title="$t('general.login')"
|
||||
:disabled="!canSubmit"
|
||||
:aria-label="submitButtonTitle"
|
||||
:title="submitButtonTitle"
|
||||
>
|
||||
{{ $t("general.login") }}
|
||||
{{ submitButtonTitle }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -45,6 +50,22 @@ export default {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
infoText: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
submitButtonTitle() {
|
||||
return this.submitLabel || this.$t("general.login");
|
||||
},
|
||||
canSubmit() {
|
||||
return String(this.password ?? "").trim().length > 0;
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -53,6 +74,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
mutations.closeTopPrompt();
|
||||
this.submitCallback(this.password);
|
||||
},
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<template>
|
||||
<div class="card-content">
|
||||
<file-list ref="fileList" @update:selected="updateSelection" :browseSource="currentSource" :showFiles="showFiles" :showFolders="showFolders">
|
||||
<file-list ref="fileList" @update:selected="updateSelection" :browseSource="currentSource"
|
||||
:browse-path="initialBrowsePath" :hide-destination-source="hideDestinationSource" :showFiles="showFiles"
|
||||
:showFolders="showFolders" :require-file-selection="requireFileSelection"
|
||||
:allowed-file-types="allowedFileTypes" :title="listTitle">
|
||||
</file-list>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
|
||||
<button class="button button--flat button--grey" type="button" @click="onCancel" :aria-label="$t('general.cancel')"
|
||||
:title="$t('general.cancel')">
|
||||
{{ $t("general.cancel") }}
|
||||
</button>
|
||||
<button type="button" class="button button--flat" @click="confirmSelection" :aria-label="$t('general.select')"
|
||||
<button type="button" class="button button--flat" @click="confirmSelection"
|
||||
:disabled="requireFileSelection && !selectionIsValid" :aria-label="$t('general.select')"
|
||||
:title="$t('general.select')">
|
||||
{{ $t("general.select") }}
|
||||
</button>
|
||||
@@ -41,40 +45,102 @@ export default {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hideDestinationSource: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/** When set, included on pathSelected so listeners can tell this pick apart from other pickers. */
|
||||
selectionContextId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
requireFileSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowedFileTypes: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
/** Shown by FileList when set (e.g. share banner/favicon picker). */
|
||||
listTitle: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedPath: "/",
|
||||
selectedSource: "",
|
||||
/** From FileList when requireFileSelection is true (valid file picked). */
|
||||
selectionIsValid: false,
|
||||
/** True after confirm or explicit cancel — used to avoid duplicate cancel events. */
|
||||
selectionFinished: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
initialBrowsePath() {
|
||||
const p = this.currentPath;
|
||||
if (p && typeof p === "string" && p.length > 0) {
|
||||
return p;
|
||||
}
|
||||
return "/";
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Initialize with current values
|
||||
this.selectedPath = this.currentPath || "/";
|
||||
this.selectedSource = this.currentSource || "";
|
||||
this.selectionIsValid = !this.requireFileSelection;
|
||||
},
|
||||
computed: {
|
||||
closeTopPrompt() {
|
||||
return mutations.closeTopPrompt();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (
|
||||
this.selectionContextId &&
|
||||
!this.selectionFinished
|
||||
) {
|
||||
eventBus.emit("pathPickerCancelled", {
|
||||
selectionContextId: this.selectionContextId,
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onCancel() {
|
||||
this.selectionFinished = true;
|
||||
if (this.selectionContextId) {
|
||||
eventBus.emit("pathPickerCancelled", {
|
||||
selectionContextId: this.selectionContextId,
|
||||
});
|
||||
}
|
||||
mutations.closeTopPrompt();
|
||||
},
|
||||
updateSelection(pathOrData) {
|
||||
// Handle both old format (just path) and new format (object with path and source)
|
||||
if (typeof pathOrData === 'string') {
|
||||
this.selectedPath = pathOrData;
|
||||
this.selectionIsValid = !this.requireFileSelection;
|
||||
} else if (pathOrData && pathOrData.path) {
|
||||
this.selectedPath = pathOrData.path;
|
||||
this.selectedSource = pathOrData.source;
|
||||
if (Object.prototype.hasOwnProperty.call(pathOrData, 'isValid')) {
|
||||
this.selectionIsValid = !!pathOrData.isValid;
|
||||
} else {
|
||||
this.selectionIsValid = !this.requireFileSelection;
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmSelection() {
|
||||
// Emit the selected path and source via eventBus
|
||||
eventBus.emit('pathSelected', {
|
||||
if (this.requireFileSelection && !this.selectionIsValid) {
|
||||
return;
|
||||
}
|
||||
this.selectionFinished = true;
|
||||
const payload = {
|
||||
path: this.selectedPath,
|
||||
source: this.selectedSource
|
||||
});
|
||||
// Close the prompt
|
||||
source: this.selectedSource,
|
||||
};
|
||||
if (this.selectionContextId) {
|
||||
payload.selectionContextId = this.selectionContextId;
|
||||
}
|
||||
eventBus.emit("pathSelected", payload);
|
||||
mutations.closeTopPrompt();
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -23,14 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPickingFile">
|
||||
<file-list @update:selected="updateTempFilePath" :browse-source="displaySource" :browse-path="displayPath"
|
||||
:show-files="true" :show-folders="true" :allowed-file-types="filePickerAllowedTypes"
|
||||
:require-file-selection="true" :title="filePickerTitle">
|
||||
</file-list>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isPickingFile">
|
||||
<div v-else>
|
||||
<div v-if="isFiles" aria-label="share-path" class="searchContext button"> {{ $t('general.path', { suffix: ':' }) }} {{
|
||||
displayPath }}</div>
|
||||
<div v-else>
|
||||
@@ -64,14 +57,14 @@
|
||||
</button>
|
||||
</td>
|
||||
<td class="small">
|
||||
<button class="action copy-clipboard" :data-clipboard-text="link.shareURL"
|
||||
<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 copy-clipboard"
|
||||
:data-clipboard-text="link.downloadURL" :aria-label="$t('buttons.copyDownloadLinkToClipboard')"
|
||||
<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>
|
||||
@@ -313,7 +306,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditingPath && !isPickingFile" class="card-actions">
|
||||
<div v-if="!isEditingPath" class="card-actions">
|
||||
<button v-if="listing" class="button button--flat button--blue" @click="() => switchListing()"
|
||||
:aria-label="$t('general.new')" :title="$t('general.new')">
|
||||
{{ $t("general.new") }}
|
||||
@@ -323,22 +316,12 @@
|
||||
{{ $t("general.share") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isPickingFile" class="card-actions">
|
||||
<button class="button button--flat" @click="cancelFilePicker" :aria-label="$t('general.cancel')"
|
||||
:title="$t('general.cancel')">
|
||||
{{ $t("general.cancel") }}
|
||||
</button>
|
||||
<button class="button button--flat button--blue" @click="confirmFilePicker" :disabled="!filePickerSelectionValid"
|
||||
:aria-label="$t('general.ok')" :title="$t('general.ok')">
|
||||
{{ $t("general.ok") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { notify } from "@/notify";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { shareApi } from "@/api";
|
||||
import Clipboard from "clipboard";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import { fromNow } from "@/utils/moment";
|
||||
import { buildItemUrl } from "@/utils/url";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
@@ -381,8 +364,6 @@ export default {
|
||||
unit: "hours",
|
||||
/** @type {Share[]} */
|
||||
links: [],
|
||||
/** @type {Clipboard | null} */
|
||||
clip: null,
|
||||
password: "",
|
||||
listing: true,
|
||||
allowModify: false,
|
||||
@@ -425,11 +406,9 @@ export default {
|
||||
tempSource: "",
|
||||
pathExists: true,
|
||||
isChangingPassword: false,
|
||||
isPickingFile: false,
|
||||
/** Set while a pathPicker for banner/favicon is open; cleared on select/cancel. */
|
||||
pendingBannerFaviconContextId: null,
|
||||
filePickerField: null, // 'banner' or 'favicon'
|
||||
filePickerSelectionValid: false, // Track if a valid file is selected
|
||||
tempFilePath: "",
|
||||
tempFileSource: "",
|
||||
showMoreExpanded: false,
|
||||
//viewMode: "normal",
|
||||
};
|
||||
@@ -490,22 +469,6 @@ export default {
|
||||
const currentLink = this.isEditMode ? this.link : this.editingLink;
|
||||
return currentLink && currentLink.hasPassword;
|
||||
},
|
||||
filePickerAllowedTypes() {
|
||||
if (this.filePickerField === 'banner') {
|
||||
return ['image/']; // Allow all image types
|
||||
} else if (this.filePickerField === 'favicon') {
|
||||
return ['image/']; // Allow all image types
|
||||
}
|
||||
return [];
|
||||
},
|
||||
filePickerTitle() {
|
||||
if (this.filePickerField === 'banner') {
|
||||
return this.$t('share.selectBannerImage');
|
||||
} else if (this.filePickerField === 'favicon') {
|
||||
return this.$t('share.selectFaviconImage');
|
||||
}
|
||||
return this.$t('share.selectImage');
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
listing(isListing) {
|
||||
@@ -593,34 +556,21 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Initialize clipboard after DOM is ready
|
||||
this.$nextTick(() => {
|
||||
this.initClipboard();
|
||||
});
|
||||
// Listen for sidebar links updates from the SidebarLinks prompt
|
||||
eventBus.on('shareSidebarLinksUpdated', this.handleSidebarLinksUpdate);
|
||||
eventBus.on('pathSelected', this.onBannerFaviconPathSelected);
|
||||
eventBus.on('pathPickerCancelled', this.onBannerFaviconPathPickerCancelled);
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up event listeners
|
||||
eventBus.off('apiKeysChanged', this.reloadApiKeys);
|
||||
eventBus.off('shareSidebarLinksUpdated', this.handleSidebarLinksUpdate);
|
||||
// Clean up clipboard
|
||||
if (this.clip) {
|
||||
this.clip.destroy();
|
||||
}
|
||||
eventBus.off('pathSelected', this.onBannerFaviconPathSelected);
|
||||
eventBus.off('pathPickerCancelled', this.onBannerFaviconPathPickerCancelled);
|
||||
},
|
||||
methods: {
|
||||
initClipboard() {
|
||||
// Destroy existing clipboard first
|
||||
if (this.clip) {
|
||||
this.clip.destroy();
|
||||
}
|
||||
|
||||
// Create new clipboard instance
|
||||
this.clip = new Clipboard(".copy-clipboard");
|
||||
this.clip.on("success", () => {
|
||||
notify.showSuccessToast(this.$t("success.linkCopied"));
|
||||
});
|
||||
async copyToClipboard(text) {
|
||||
await copyToClipboard(text);
|
||||
},
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
@@ -707,10 +657,6 @@ export default {
|
||||
if (!this.isEditMode && !this.editingLink) {
|
||||
this.links.push(res);
|
||||
this.sort();
|
||||
// reinitialize the clipboard after adding a new link
|
||||
this.$nextTick(() => {
|
||||
this.initClipboard();
|
||||
});
|
||||
} else if (this.editingLink) {
|
||||
// Update the link in the local list
|
||||
const index = this.links.findIndex(l => l.hash === this.editingLink.hash);
|
||||
@@ -720,10 +666,6 @@ export default {
|
||||
this.editingLink = null;
|
||||
// emit event to reload shares in settings view
|
||||
eventBus.emit('sharesChanged');
|
||||
// Reinitialize clipboard after edit the share
|
||||
this.$nextTick(() => {
|
||||
this.initClipboard();
|
||||
});
|
||||
} else {
|
||||
// emit event to reload shares in settings view
|
||||
eventBus.emit('sharesChanged');
|
||||
@@ -798,10 +740,6 @@ export default {
|
||||
try {
|
||||
await shareApi.remove(link.hash);
|
||||
this.links = this.links.filter((item) => item.hash !== link.hash);
|
||||
// Reinitialize clipboard after deletion too
|
||||
this.$nextTick(() => {
|
||||
this.initClipboard();
|
||||
});
|
||||
// emit event to reload shares in settings view
|
||||
eventBus.emit('sharesChanged');
|
||||
if (this.links.length === 0) {
|
||||
@@ -933,42 +871,70 @@ export default {
|
||||
});
|
||||
},
|
||||
openBannerPicker() {
|
||||
const selectionContextId = `share-banner-favicon-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
this.pendingBannerFaviconContextId = selectionContextId;
|
||||
this.filePickerField = 'banner';
|
||||
this.tempFilePath = this.displayPath;
|
||||
this.tempFileSource = this.displaySource;
|
||||
this.isPickingFile = true;
|
||||
mutations.showPrompt({
|
||||
name: 'pathPicker',
|
||||
pinned: true,
|
||||
props: {
|
||||
currentPath: this.displayPath || '/',
|
||||
currentSource: this.displaySource,
|
||||
hideDestinationSource: true,
|
||||
showFiles: true,
|
||||
showFolders: true,
|
||||
requireFileSelection: true,
|
||||
allowedFileTypes: ['image/'],
|
||||
selectionContextId,
|
||||
listTitle: this.$t('share.selectBannerImage'),
|
||||
},
|
||||
});
|
||||
},
|
||||
openFaviconPicker() {
|
||||
const selectionContextId = `share-banner-favicon-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
this.pendingBannerFaviconContextId = selectionContextId;
|
||||
this.filePickerField = 'favicon';
|
||||
this.tempFilePath = this.displayPath;
|
||||
this.tempFileSource = this.displaySource;
|
||||
this.isPickingFile = true;
|
||||
mutations.showPrompt({
|
||||
name: 'pathPicker',
|
||||
pinned: true,
|
||||
props: {
|
||||
currentPath: this.displayPath || '/',
|
||||
currentSource: this.displaySource,
|
||||
hideDestinationSource: true,
|
||||
showFiles: true,
|
||||
showFolders: true,
|
||||
requireFileSelection: true,
|
||||
allowedFileTypes: ['image/'],
|
||||
selectionContextId,
|
||||
listTitle: this.$t('share.selectFaviconImage'),
|
||||
},
|
||||
});
|
||||
},
|
||||
updateTempFilePath(pathOrData) {
|
||||
if (pathOrData && pathOrData.path) {
|
||||
this.tempFilePath = pathOrData.path;
|
||||
this.tempFileSource = pathOrData.source;
|
||||
this.filePickerSelectionValid = pathOrData.isValid || false;
|
||||
/**
|
||||
* @param {{ path: string, source: string, selectionContextId?: string }} data
|
||||
*/
|
||||
onBannerFaviconPathSelected(data) {
|
||||
if (!this.pendingBannerFaviconContextId || !data || data.selectionContextId !== this.pendingBannerFaviconContextId) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
confirmFilePicker() {
|
||||
if (this.tempFilePath && this.tempFileSource) {
|
||||
// Build query params format: source=sourcename&path=indexpath
|
||||
const queryParams = `source=${encodeURIComponent(this.tempFileSource)}&path=${encodeURIComponent(this.tempFilePath)}`;
|
||||
|
||||
if (this.filePickerField === 'banner') {
|
||||
this.banner = queryParams;
|
||||
} else if (this.filePickerField === 'favicon') {
|
||||
this.favicon = queryParams;
|
||||
}
|
||||
const queryParams = `source=${encodeURIComponent(data.source)}&path=${encodeURIComponent(data.path)}`;
|
||||
if (this.filePickerField === 'banner') {
|
||||
this.banner = queryParams;
|
||||
} else if (this.filePickerField === 'favicon') {
|
||||
this.favicon = queryParams;
|
||||
}
|
||||
this.isPickingFile = false;
|
||||
this.pendingBannerFaviconContextId = null;
|
||||
this.filePickerField = null;
|
||||
},
|
||||
cancelFilePicker() {
|
||||
this.isPickingFile = false;
|
||||
/**
|
||||
* @param {{ selectionContextId?: string }} data
|
||||
*/
|
||||
onBannerFaviconPathPickerCancelled(data) {
|
||||
if (!this.pendingBannerFaviconContextId || !data || data.selectionContextId !== this.pendingBannerFaviconContextId) {
|
||||
return;
|
||||
}
|
||||
this.pendingBannerFaviconContextId = null;
|
||||
this.filePickerField = null;
|
||||
this.filePickerSelectionValid = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,11 +59,9 @@
|
||||
|
||||
<!-- Link Type Selection -->
|
||||
<p>{{ $t('sidebar.linkType') }}</p>
|
||||
<select aria-label="Link Type" v-model="newLink.category" @change="handleCategoryChange" class="input">
|
||||
<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 v-if="context === 'user'" value="source-minimal">{{ $t('general.source') }}</option>
|
||||
<option v-if="context === 'user'" value="source-hybrid">{{ $t('general.source') }}</option>
|
||||
<option value="share">{{ $t('general.share') }}</option>
|
||||
<option v-if="context === 'user'" value="tool">{{ $t('general.tool') }}</option>
|
||||
<option value="custom">{{ $t('sidebar.customLink') }}</option>
|
||||
@@ -342,6 +340,13 @@ export default {
|
||||
}
|
||||
return 'indexed';
|
||||
},
|
||||
/** Single "Source" row in the link-type dropdown; actual variant stays in newLink.category (toggles). */
|
||||
linkTypeSelectValue() {
|
||||
if (this.isSourceCategory(this.newLink.category)) {
|
||||
return 'source';
|
||||
}
|
||||
return this.newLink.category;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
// Initialize with existing sidebar links based on context
|
||||
@@ -496,21 +501,32 @@ export default {
|
||||
}
|
||||
return link.name;
|
||||
},
|
||||
handleCategoryChange() {
|
||||
// Reset newLink fields when category changes
|
||||
resetNewLinkFormForTypeSwitch() {
|
||||
this.newLink.name = "";
|
||||
this.newLink.target = "";
|
||||
this.newLink.icon = "";
|
||||
this.newLink.sourceName = "";
|
||||
this.newLink.sourcePath = "";
|
||||
|
||||
// Set default name and icon for special share link types
|
||||
if (this.newLink.category === "shareInfo") {
|
||||
this.newLink.name = this.$t("share.shareInfo");
|
||||
this.newLink.icon = "qr_code";
|
||||
} else if (this.newLink.category === "download") {
|
||||
this.newLink.name = this.$t("general.download");
|
||||
this.newLink.icon = "download";
|
||||
},
|
||||
onLinkTypeChange(event) {
|
||||
const value = event.target.value;
|
||||
if (value === "source") {
|
||||
if (!this.isSourceCategory(this.newLink.category)) {
|
||||
this.resetNewLinkFormForTypeSwitch();
|
||||
this.newLink.category = "source";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value !== this.newLink.category || this.isSourceCategory(this.newLink.category)) {
|
||||
this.resetNewLinkFormForTypeSwitch();
|
||||
this.newLink.category = value;
|
||||
if (value === "shareInfo") {
|
||||
this.newLink.name = this.$t("share.shareInfo");
|
||||
this.newLink.icon = "qr_code";
|
||||
} else if (value === "download") {
|
||||
this.newLink.name = this.$t("general.download");
|
||||
this.newLink.icon = "download";
|
||||
}
|
||||
}
|
||||
},
|
||||
handleSourceChange() {
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
<qrcode-vue class="qrcode" :value="url" :size="200" level="M"></qrcode-vue>
|
||||
</div>
|
||||
<p>{{ $t("otp.verifyInstructions") }}</p>
|
||||
<input :class="{'form-invalid': error != '' && code != ''}" v-focus class="input" type="text" v-model="code" @keyup.enter="verifyCode"
|
||||
<input :class="{'form-invalid': error != '' && code != ''}" v-focus class="input" type="text" v-model="code"
|
||||
@keydown.enter.prevent="verifyCode"
|
||||
:placeholder="$t('otp.codeInputPlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button v-if="!succeeded && code != ''" class="button button--flat button--blue" @click="verifyCode"
|
||||
<button v-if="!succeeded && code != ''" type="button" class="button button--flat button--blue" @click="verifyCode"
|
||||
:title="$t('general.verify')">
|
||||
{{ $t("general.verify") }}
|
||||
</button>
|
||||
@@ -37,6 +38,7 @@ export default {
|
||||
code: "",
|
||||
url: "",
|
||||
succeeded: false,
|
||||
verifyInFlight: false,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
@@ -59,6 +61,10 @@ export default {
|
||||
},
|
||||
async mounted() {
|
||||
if (this.generate) {
|
||||
if (String(this.password ?? "").trim() === "") {
|
||||
this.error = this.$t("otp.passwordRequiredForGeneration");
|
||||
return;
|
||||
}
|
||||
this.generateNewCode();
|
||||
}
|
||||
},
|
||||
@@ -69,6 +75,10 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async generateNewCode() {
|
||||
if (String(this.password ?? "").trim() === "") {
|
||||
this.error = this.$t("otp.passwordRequiredForGeneration");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await authApi.generateOTP(this.username, this.password);
|
||||
this.url = resp.url;
|
||||
@@ -78,28 +88,39 @@ export default {
|
||||
}
|
||||
},
|
||||
async verifyCode(event) {
|
||||
event.preventDefault();
|
||||
event?.preventDefault?.();
|
||||
if (this.verifyInFlight || this.succeeded) {
|
||||
return;
|
||||
}
|
||||
if (!this.code) {
|
||||
this.error = this.$t("otp.invalidCodeType");
|
||||
notify.showError(this.$t("otp.invalidCodeType"));
|
||||
return;
|
||||
}
|
||||
if (String(this.password ?? "").trim() === "") {
|
||||
this.error = this.$t("otp.passwordRequiredForGeneration");
|
||||
notify.showError(this.$t("otp.passwordRequiredForGeneration"));
|
||||
return;
|
||||
}
|
||||
this.verifyInFlight = true;
|
||||
try {
|
||||
await authApi.verifyOTP(this.username, this.password, this.code);
|
||||
if (this.redirect != "") {
|
||||
await authApi.login(this.username, this.password, this.redirect, this.code);
|
||||
await initAuth();
|
||||
this.$router.push(this.redirect);
|
||||
// Wait for navigation so Layout mounts and registers the toast callback before we toast
|
||||
await this.$router.push(this.redirect);
|
||||
}
|
||||
mutations.updateCurrentUser({ otpEnabled: true });
|
||||
this.succeeded = true
|
||||
this.succeeded = true;
|
||||
this.error = "";
|
||||
notify.showSuccessToast(this.$t("otp.verificationSucceed"));
|
||||
mutations.closeTopPrompt();
|
||||
} catch (error) {
|
||||
this.error = this.$t("otp.verificationFailed");
|
||||
console.log("error", error);
|
||||
return;
|
||||
} finally {
|
||||
this.verifyInFlight = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
:description="$t('fileLoading.clearAllDescription')" />
|
||||
</div>
|
||||
</SettingsItem>
|
||||
<div class="upload-prompt" :class="{ dropping: isDragging }" @dragenter.prevent="onDragEnter"
|
||||
<div class="upload-prompt" :class="{ dropping: isDragging }"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragOver" @dragleave.prevent="onDragLeave" @drop.prevent="onDrop">
|
||||
<div class="upload-prompt-container">
|
||||
<i v-if="files.length === 0" class="material-symbols">cloud_upload</i>
|
||||
@@ -132,7 +133,7 @@
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { uploadManager } from "@/utils/upload";
|
||||
import { readAllDirectoryEntries, uploadManager } from "@/utils/upload";
|
||||
import { mutations, state } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
import { usersApi } from "@/api";
|
||||
@@ -485,9 +486,8 @@ export default {
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const reader = entry.createReader();
|
||||
const entries = await new Promise((resolve) => {
|
||||
reader.readEntries((e) => resolve(e));
|
||||
});
|
||||
const pathLabel = entry.fullPath || entry.name || "";
|
||||
const entries = await readAllDirectoryEntries(reader, pathLabel);
|
||||
const allFiles = await Promise.all(
|
||||
entries.map((subEntry) => getFilesFromDirectoryEntry(subEntry))
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
|
||||
<span>{{ $t("files.lonely") }}</span>
|
||||
</h2>
|
||||
<div v-if="user.loginMethod == 'password' && globalVars.passwordAvailable && !isNew">
|
||||
<div v-if="showPasswordChangeSection">
|
||||
<label for="password">{{ $t("general.password") }}</label>
|
||||
<div class="form-flex-group">
|
||||
<input class="input form-form" :class="{ 'form-invalid': invalidPassword }" aria-label="Password1"
|
||||
@@ -16,7 +16,13 @@
|
||||
<input class="input form-form" :class="{ 'flat-right': !isNew, 'form-invalid': invalidPassword }"
|
||||
aria-label="Password2" type="password" autocomplete="new-password"
|
||||
:placeholder="$t('settings.enterPasswordAgain')" v-model="user.password" id="password" />
|
||||
<button v-if="!isNew" type="button" class="button form-button flat-left" @click="submitUpdatePassword">
|
||||
<button
|
||||
v-if="!isNew"
|
||||
type="button"
|
||||
class="button form-button flat-left"
|
||||
:disabled="!canUpdatePassword"
|
||||
@click="submitUpdatePassword"
|
||||
>
|
||||
{{ $t("general.update") }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -46,7 +52,13 @@
|
||||
<input class="input form-form" :class="{ 'flat-right': !isNew, 'form-invalid': invalidPassword }"
|
||||
type="password" :placeholder="$t('settings.enterPasswordAgain')" aria-label="Password2"
|
||||
v-model="user.password" autocomplete="new-password" id="password" />
|
||||
<button v-if="!isNew" type="button" class="button form-button flat-left" @click="submitUpdatePassword">
|
||||
<button
|
||||
v-if="!isNew"
|
||||
type="button"
|
||||
class="button form-button flat-left"
|
||||
:disabled="!canUpdatePassword"
|
||||
@click="submitUpdatePassword"
|
||||
>
|
||||
{{ $t("general.update") }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -62,16 +74,20 @@
|
||||
<label for="scopes">{{ $t("settings.scopes") }}</label>
|
||||
<div class="scope-list" :class="{ 'form-invalid': duplicateSources.includes(source.name) }"
|
||||
v-for="(source, index) in selectedSources" :key="index">
|
||||
<select @change="handleSourceChange(source, $event, source.name)" class="input flat-right"
|
||||
<select @change="handleSourceChange(source, $event, source.name)" class="input flat-right source-dropdown"
|
||||
v-model="source.name">
|
||||
<option v-for="s in sourceList" :key="s.name" :value="s.name">
|
||||
{{ s.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input class="input flat-left scope-input" :placeholder="$t('settings.newUserHintSubFolder')"
|
||||
@input="updateParent({ source: source, input: $event })" :value="source.scope"
|
||||
:class="{ 'flat-right': selectedSources.length > 1 }" />
|
||||
<div
|
||||
:aria-label="'user-edit-scope-path-' + index"
|
||||
class="clickable button flat-left scope-path-display"
|
||||
:class="{ 'flat-right': selectedSources.length > 1 }"
|
||||
@click="onScopePathRowClick(index, source)"
|
||||
>{{ scopePathDisplay(source) }}
|
||||
</div>
|
||||
<button v-if="selectedSources.length > 1" class="button flat-left no-height" @click="removeScope(index)">
|
||||
<i class="material-symbols material-size">delete</i>
|
||||
</button>
|
||||
@@ -82,11 +98,6 @@
|
||||
<i class="material-symbols material-size">add</i>
|
||||
</button>
|
||||
|
||||
<div class="settings-items">
|
||||
<ToggleSwitch v-if="displayHomeDirectoryCheckbox" class="item" v-model="createUserDir"
|
||||
:name="$t('settings.createUserHomeDirectory')" />
|
||||
</div>
|
||||
|
||||
<p v-if="stateUser.username !== user.username">
|
||||
<label for="locale">{{ $t("general.language") }}</label>
|
||||
<languages class="input" id="locale" v-model:locale="user.locale" @input="emitUpdate"></languages>
|
||||
@@ -169,12 +180,22 @@ export default {
|
||||
availableSources: [],
|
||||
selectedSources: [],
|
||||
passwordRef: "",
|
||||
pendingScopeSelectionContextId: null,
|
||||
pendingScopeIndex: null,
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
await this.fetchData();
|
||||
await this.initializeForm();
|
||||
},
|
||||
mounted() {
|
||||
eventBus.on("pathSelected", this.onPathSelectedFromPicker);
|
||||
eventBus.on("pathPickerCancelled", this.onPathPickerCancelled);
|
||||
},
|
||||
beforeUnmount() {
|
||||
eventBus.off("pathSelected", this.onPathSelectedFromPicker);
|
||||
eventBus.off("pathPickerCancelled", this.onPathPickerCancelled);
|
||||
},
|
||||
computed: {
|
||||
actor() {
|
||||
return state.user;
|
||||
@@ -193,6 +214,15 @@ export default {
|
||||
this.user.password != this.passwordRef && this.user.password.length > 0;
|
||||
return matching;
|
||||
},
|
||||
/** Update is allowed only when both password fields are non-empty (trimmed) and match. */
|
||||
canUpdatePassword() {
|
||||
const a = String(this.passwordRef ?? "").trim();
|
||||
const b = String(this.user.password ?? "").trim();
|
||||
if (a.length === 0 || b.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return !this.invalidPassword;
|
||||
},
|
||||
passwordAvailable: () => globalVars.passwordAvailable,
|
||||
globalVars: () => globalVars,
|
||||
duplicateSources() {
|
||||
@@ -205,8 +235,14 @@ export default {
|
||||
passwordPlaceholder() {
|
||||
return this.isNew ? "" : this.$t("settings.avoidChanges");
|
||||
},
|
||||
displayHomeDirectoryCheckbox() {
|
||||
return this.isNew && this.createUserDir;
|
||||
/** Password change (existing user): target and signed-in user must both use password login. */
|
||||
showPasswordChangeSection() {
|
||||
return (
|
||||
!this.isNew &&
|
||||
this.user.loginMethod === "password" &&
|
||||
this.stateUser.loginMethod === "password" &&
|
||||
this.globalVars.passwordAvailable
|
||||
);
|
||||
},
|
||||
firstAvailableLoginMethod() {
|
||||
if (this.globalVars.passwordAvailable) return "password";
|
||||
@@ -217,10 +253,6 @@ export default {
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
createUserDir(newVal) {
|
||||
this.user.scopes = newVal ? { default: "" } : this.originalUserScope;
|
||||
this.emitUserUpdate();
|
||||
},
|
||||
stateUser() {
|
||||
this.user.otpEnabled = state.user.otpEnabled;
|
||||
this.emitUserUpdate();
|
||||
@@ -330,7 +362,6 @@ export default {
|
||||
deletePrompt() {
|
||||
mutations.showPrompt({
|
||||
name: "generic",
|
||||
pinned: true,
|
||||
props: {
|
||||
title: this.$t("general.delete"),
|
||||
body: this.$t("prompts.deleteUserMessage", { username: this.user.username }),
|
||||
@@ -339,14 +370,16 @@ export default {
|
||||
label: this.$t("general.delete"),
|
||||
action: async () => {
|
||||
try {
|
||||
await usersApi.remove(this.user.id);
|
||||
await usersApi.remove(this.user.id, {
|
||||
actorPasswordPromptI18nKey: "prompts.confirmPasswordToSaveUser",
|
||||
});
|
||||
notify.showSuccessToast(this.$t("settings.userDeleted"));
|
||||
eventBus.emit('usersChanged');
|
||||
mutations.closeTopPrompt(); // close delete user prompt confirmation
|
||||
mutations.closeTopPrompt(); // close user prompt since user doens't exist anymore
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notify.showErrorToast(this.$t("settings.userDeleteFailed"));
|
||||
notify.showError(e);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -370,46 +403,55 @@ export default {
|
||||
notify.showError(this.$t("settings.userNotAdmin"));
|
||||
return;
|
||||
}
|
||||
await usersApi.create({ ...this.user, scopes: scopesToSend });
|
||||
await usersApi.create(
|
||||
{ ...this.user, scopes: scopesToSend },
|
||||
{
|
||||
actorPasswordPromptI18nKey: "prompts.confirmPasswordToSaveUser",
|
||||
}
|
||||
);
|
||||
// Emit event to refresh user list
|
||||
eventBus.emit('usersChanged');
|
||||
// Close the prompt
|
||||
mutations.closeTopPrompt();
|
||||
} else {
|
||||
await usersApi.update({ ...this.user, scopes: scopesToSend }, fields);
|
||||
// Only emit usersChanged for admin user management, not profile updates
|
||||
if (state.user.permissions.admin && this.user.id !== state.user.id) {
|
||||
eventBus.emit('usersChanged');
|
||||
}
|
||||
eventBus.emit('usersChanged');
|
||||
notify.showSuccessToast(this.$t("settings.userUpdated"));
|
||||
mutations.closeTopPrompt();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notify.showError(e);
|
||||
}
|
||||
},
|
||||
newOTP() {
|
||||
mutations.showPrompt({
|
||||
name: "totp",
|
||||
name: "password",
|
||||
props: {
|
||||
generate: true,
|
||||
username: this.user.username,
|
||||
password: this.passwordRef || this.user.password || "",
|
||||
infoText: this.$t("prompts.confirmPasswordToSaveUser"),
|
||||
submitLabel: this.$t("general.confirm"),
|
||||
submitCallback: (accountPassword) => {
|
||||
mutations.showPrompt({
|
||||
name: "totp",
|
||||
props: {
|
||||
generate: true,
|
||||
username: this.user.username,
|
||||
password: accountPassword,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
async submitUpdatePassword() {
|
||||
event.preventDefault();
|
||||
if (this.invalidPassword) {
|
||||
notify.showError(this.$t("settings.passwordsDoNotMatch"));
|
||||
if (!this.canUpdatePassword) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await usersApi.update(this.user, ["password"]);
|
||||
// Only emit usersChanged for admin user management, not profile updates
|
||||
if (state.user.permissions.admin && this.user.id !== state.user.id) {
|
||||
eventBus.emit('usersChanged');
|
||||
}
|
||||
await usersApi.update(this.user, ["password"], {
|
||||
actorPasswordPromptI18nKey: "prompts.confirmPasswordToSaveUser",
|
||||
});
|
||||
eventBus.emit("usersChanged");
|
||||
notify.showSuccessToast(this.$t("settings.userUpdated"));
|
||||
} catch (e) {
|
||||
notify.showError(e);
|
||||
@@ -434,13 +476,67 @@ export default {
|
||||
setUpdatePassword() {
|
||||
// This method is kept for compatibility but not used in the new structure
|
||||
},
|
||||
updateParent(input) {
|
||||
const updatedScopes = this.selectedSources.map((source) =>
|
||||
source.name === input.source.name
|
||||
? { ...source, scope: input.input.target.value }
|
||||
: source
|
||||
onScopePathRowClick(index, source) {
|
||||
if (!source || !source.name) {
|
||||
return;
|
||||
}
|
||||
this.openScopePicker(index);
|
||||
},
|
||||
scopePathDisplay(source) {
|
||||
const s = source && source.scope;
|
||||
if (s !== undefined && s !== null && String(s).length > 0) {
|
||||
return s;
|
||||
}
|
||||
return "/";
|
||||
},
|
||||
openScopePicker(index) {
|
||||
const row = this.selectedSources[index];
|
||||
if (!row || !row.name) {
|
||||
return;
|
||||
}
|
||||
const selectionContextId = `user-scope-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
this.pendingScopeSelectionContextId = selectionContextId;
|
||||
this.pendingScopeIndex = index;
|
||||
const initialPath =
|
||||
row.scope && typeof row.scope === "string" && row.scope.length > 0 ? row.scope : "/";
|
||||
mutations.showPrompt({
|
||||
name: "pathPicker",
|
||||
props: {
|
||||
currentPath: initialPath,
|
||||
currentSource: row.name,
|
||||
hideDestinationSource: true,
|
||||
selectionContextId,
|
||||
},
|
||||
});
|
||||
},
|
||||
onPathPickerCancelled(data) {
|
||||
if (!this.pendingScopeSelectionContextId || !data) {
|
||||
return;
|
||||
}
|
||||
if (data.selectionContextId !== this.pendingScopeSelectionContextId) {
|
||||
return;
|
||||
}
|
||||
this.pendingScopeSelectionContextId = null;
|
||||
this.pendingScopeIndex = null;
|
||||
},
|
||||
onPathSelectedFromPicker(data) {
|
||||
if (!this.pendingScopeSelectionContextId) {
|
||||
return;
|
||||
}
|
||||
if (!data || data.selectionContextId !== this.pendingScopeSelectionContextId) {
|
||||
return;
|
||||
}
|
||||
this.pendingScopeSelectionContextId = null;
|
||||
const idx = this.pendingScopeIndex;
|
||||
this.pendingScopeIndex = null;
|
||||
if (idx === null || idx === undefined || typeof data.path !== "string") {
|
||||
return;
|
||||
}
|
||||
const path = data.path;
|
||||
const next = this.selectedSources.map((s, i) =>
|
||||
i === idx ? { ...s, scope: path } : s
|
||||
);
|
||||
this.selectedSources = updatedScopes;
|
||||
this.selectedSources = next;
|
||||
this.emitUserUpdate();
|
||||
},
|
||||
addNewScopeSource(event) {
|
||||
@@ -498,9 +594,14 @@ export default {
|
||||
<style scoped>
|
||||
.scope-list {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.scope-input {
|
||||
.source-dropdown {
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.scope-path-display {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</a>
|
||||
<!-- Source-type links (source, source-minimal, source-alt, source-hybrid, source-hybrid-2); usage bar hidden for source-minimal -->
|
||||
<a v-else-if="link.category === 'source' || link.category === 'source-minimal' || link.category === 'source-alt' || link.category === 'source-hybrid' || link.category === 'source-hybrid-2'" :href="getLinkHref(link)"
|
||||
<a v-else-if="isSourceCategory(link.category)" :href="getLinkHref(link)"
|
||||
class="action button source-button sidebar-link-button" :class="{
|
||||
active: isLinkActive(link),
|
||||
disabled: !isLinkAccessible(link)
|
||||
@@ -318,8 +318,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
isSourceCategory(category) {
|
||||
return category === 'source' || category === 'source-minimal' || category === 'source-alt' ||
|
||||
category === 'source-hybrid' || category === 'source-hybrid-2';
|
||||
return category.startsWith("source")
|
||||
},
|
||||
getIconClass,
|
||||
hasUsageInfo(link) {
|
||||
|
||||
+19
-21
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"buttons": {
|
||||
"copySuccess": "تم النسخ إلى الحافظة!",
|
||||
"copyFailed": "فشل النسخ إلى الحافظة",
|
||||
"copyFile": "نسخ الملف",
|
||||
"copyToClipboard": "نسخ الى الحافظة",
|
||||
"moveFile": "نقل الملف",
|
||||
@@ -33,7 +34,8 @@
|
||||
"showMore": "إظهار الخيارات المتقدمة",
|
||||
"showLess": "إخفاء الخيارات المتقدمة",
|
||||
"goToSource": "الانتقال إلى موقع المصدر",
|
||||
"goToItem": "انتقل إلى العنصر"
|
||||
"goToItem": "انتقل إلى العنصر",
|
||||
"copyPath": "مسار النسخ"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Download File",
|
||||
@@ -214,8 +216,6 @@
|
||||
"selectHashAlgorithm": "تحديد خوارزمية تجزئة",
|
||||
"generate": "توليد",
|
||||
"errorGeneratingHash": "خطأ في توليد التجزئة",
|
||||
"hashCopied": "التجزئة المنسوخة إلى الحافظة",
|
||||
"errorCopyingHash": "خطأ في نسخ تجزئة التجزئة",
|
||||
"token": "الرمز المميز",
|
||||
"onlyOfficeId": "معرّف المكتب فقط",
|
||||
"firstLoadTitle": "مرحبًا بك في متصفح الملفات الكمي!",
|
||||
@@ -226,7 +226,6 @@
|
||||
"renameSuccess": "تمت إعادة تسمية العنصر بنجاح!",
|
||||
"newFileSuccess": "تم إنشاء الملف بنجاح!",
|
||||
"newDirSuccess": "تم إنشاء الدليل بنجاح!",
|
||||
"copyToClipboardFailed": "تعذر النسخ إلى الحافظة",
|
||||
"moveFailed": "فشلت عملية النقل",
|
||||
"operationFailed": "فشلت العملية",
|
||||
"partialSuccess": "فشلت بعض العمليات: {succeeded} نجحت ، {failed} فشلت ",
|
||||
@@ -241,14 +240,12 @@
|
||||
"unarchiveMessage": "اختر مكان استخراج الأرشيف.",
|
||||
"unarchiveDestination": "مقتطف إلى:",
|
||||
"unarchiveSuccess": "تم استخراج الأرشيف بنجاح!",
|
||||
"downloadFailed": "فشل التحميل المقطوع - إذا تكرر ذلك، حاول تقليل حجم القطعة: {message}"
|
||||
"downloadFailed": "فشل التحميل المقطوع - إذا تكرر ذلك، حاول تقليل حجم القطعة: {message}",
|
||||
"confirmPasswordToSaveUser": "أدخل كلمة المرور الحالية لحفظ تغييرات المستخدم هذه."
|
||||
},
|
||||
"search": {
|
||||
"smallerThan": "أصغر من:",
|
||||
"largerThan": "أكبر من:",
|
||||
"helpText1": "يحدث البحث على كل حرف تكتبه (3 أحرف كحد أدنى لمصطلحات البحث).",
|
||||
"helpText2": "الفهرس: يستخدم البحث الفهرس الذي يتم تحديثه تلقائيًا على الفاصل الزمني الذي تم تكوينه (افتراضيًا: 5 دقائق). قد يؤدي البحث عندما يكون البرنامج قد بدأ للتو إلى نتائج غير مكتملة.",
|
||||
"noResults": "لم يتم العثور على نتائج في البحث المفهرس.",
|
||||
"onlyFolders": "المجلدات فقط",
|
||||
"onlyFiles": "الملفات فقط",
|
||||
"showOptions": "عرض الخيارات",
|
||||
@@ -258,7 +255,10 @@
|
||||
"showPreviewImages": "عرض صور المعاينة",
|
||||
"showPreviewImagesDescription": "عرض معاينات الصور المصغرة لنتائج البحث عند توفرها، وإظهار أيقونات أكبر لجميع النتائج.",
|
||||
"olderThanDate": "أقدم من:",
|
||||
"newerThanDate": "أحدث من:"
|
||||
"newerThanDate": "أحدث من:",
|
||||
"helpText2": "تعتمد نتائج البحث على الملفات المفهرسة، لذلك قد لا تظهر الملفات المضافة حديثاً حتى تتم فهرستها.",
|
||||
"noResults": "لم يتم العثور على نتائج.",
|
||||
"helpText1": "يحدث البحث على كل حرف تكتبه ({minSearchLength} حرف كحد أدنى)."
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "Administrator",
|
||||
@@ -326,7 +326,6 @@
|
||||
"tusUploadsRetryCount": "عدد مرات إعادة المحاولة التي يجب إجراؤها في حالة فشل تحميل قطعة ما.",
|
||||
"userHomeBasePath": "المسار الأساسي للدلائل الرئيسية للمستخدم",
|
||||
"userScopeGenerationPlaceholder": "سيتم إنشاء النطاق تلقائيًا",
|
||||
"createUserHomeDirectory": "إنشاء الدليل الرئيسي للمستخدم",
|
||||
"shareDeleted": "تم حذف المشاركة",
|
||||
"loginMethodDescription": "طريقة تسجيل الدخول المسموح بها للمستخدم.",
|
||||
"userNotAdmin": "المستخدم ليس مسؤولاً",
|
||||
@@ -542,7 +541,8 @@
|
||||
"enabledSuccessfully": "تم تمكين OTP بنجاح",
|
||||
"verificationFailed": "فشل التحقق",
|
||||
"verificationSucceed": "نجاح التحقق",
|
||||
"generationFailed": "فشل التوليد"
|
||||
"generationFailed": "فشل التوليد",
|
||||
"passwordRequiredForGeneration": "كلمة المرور الخاصة بك مطلوبة لإنشاء رمز المصادقة."
|
||||
},
|
||||
"general": {
|
||||
"name": "الاسم",
|
||||
@@ -579,7 +579,6 @@
|
||||
"results": "النتائج{suffix}",
|
||||
"users": "المستخدمون{suffix}",
|
||||
"photos": "الصور",
|
||||
"audios": "ملفات صوتية{suffix}",
|
||||
"videos": "مقاطع الفيديو{suffix}",
|
||||
"models": "نماذج ثلاثية الأبعاد",
|
||||
"documents": "المستندات{suffix}",
|
||||
@@ -665,7 +664,8 @@
|
||||
"format": "التنسيق{suffix}",
|
||||
"office": "المكتب{suffix}",
|
||||
"show": "عرض{suffix}",
|
||||
"divider": "مقسم{suffix}"
|
||||
"divider": "مقسم{suffix}",
|
||||
"audio": "الصوت{suffix}"
|
||||
},
|
||||
"access": {
|
||||
"rules": "قواعد الوصول",
|
||||
@@ -709,14 +709,11 @@
|
||||
"deleteAfterArchiveDescription": "عند التمكين، سيتم حذف الملفات المصدر بعد إنشاء/استخراج الأرشيفات بنجاح",
|
||||
"hideFilesInTree": "إخفاء الملفات في شجرة التنقل",
|
||||
"hideFilesInTreeDescription": "عند التمكين، تظهر المجلدات فقط في التنقل الشجري للشريط الجانبي",
|
||||
"previewModelsDescription": "عرض صور مصغرة للنماذج ثلاثية الأبعاد، سيتم عرض أيقونة عامة إذا كانت معطلة",
|
||||
"setDateFormat": "تعيين تنسيق التاريخ الدقيق",
|
||||
"showHiddenFiles": "إظهار الملفات المخفية",
|
||||
"showHiddenFilesDescription": "إظهار الملفات المخفية في القائمة (مثل .thumbnails، ProgramData)، ولكن لا يزال يمكن الوصول إليها يدويًا إذا تم تكوينها للفهرسة.",
|
||||
"showQuickDownload": "إظهار أيقونة التنزيل للوصول السريع",
|
||||
"showQuickDownloadDescription": "إظهار أيقونة التنزيل للوصول السريع إلى كل عنصر ملف يمكن النقر عليها لتنزيله على الفور.",
|
||||
"previewImagesDescription": "إظهار معاينات الصور المصغرة للصور، ستظهر أيقونة عامة إذا تم تعطيلها.",
|
||||
"previewVideosDescription": "إظهار معاينات الصور المصغرة لمقاطع الفيديو، ستظهر أيقونة عامة إذا تم تعطيلها.",
|
||||
"editOffice": "عرض وتحرير الملفات المكتبية باستخدام OnlyOffice",
|
||||
"editOfficeDescription": "عند تمكين Office فقط، سيتم تعطيل عارض/محرر الملفات لجميع امتدادات الملفات المتعلقة بملفات Office.",
|
||||
"filePreviewOptions": "خيارات معاينة الملف",
|
||||
@@ -761,12 +758,14 @@
|
||||
"previewFolderDescription": "إظهار معاينات الصور المصغرة للمجلدات التي تحتوي على العنصر الفرعي الأول كغلاف - أو عناصر متعددة إذا تم تمكين معاينة الحركة.",
|
||||
"previewMotion": "معاينات الحركة",
|
||||
"previewMotionVideosDescription": "معاينة الحركة هي المعاينة المنبثقة المتحركة التي تتنقل عبر إطارات الفيديو أو الصور المتعددة لمجلد",
|
||||
"previewAudiosDescription": "عرض صور مصغرة للملفات الصوتية، سيتم عرض أيقونة عامة إذا كانت معطلة",
|
||||
"popupPreview": "العارض المنبثق",
|
||||
"popupPreviewDescription": "ستظهر نافذة منبثقة متراكبة فوق واجهة المستخدم عند التمرير فوق العناصر ذات الصور المصغرة",
|
||||
"thumbnailOptions": "خيارات الصور المصغرة",
|
||||
"showThumbnails": "إظهار الصور المصغرة للعناصر المدعومة",
|
||||
"showThumbnailsDescription": "عند التمكين، تظهر الصور المصغرة في القائمة للعناصر التي تدعمها. قم بإيقاف التشغيل لإخفاء جميع الصور المصغرة."
|
||||
"showThumbnailsDescription": "عند التمكين، تظهر الصور المصغرة في القائمة للعناصر التي تدعمها. قم بإيقاف التشغيل لإخفاء جميع الصور المصغرة.",
|
||||
"showCopyPath": "إظهار مسار النسخ في قائمة السياق",
|
||||
"showCopyPathDescription": "إظهار إجراء نسخ المسار في قائمة سياق الملف والمجلد.",
|
||||
"previewDescription": "إظهار معاينات الصور المصغرة لـ {filetype} ، ستظهر أيقونة عامة إذا تم تعطيلها."
|
||||
},
|
||||
"colors": {
|
||||
"red": "أحمر",
|
||||
@@ -844,8 +843,6 @@
|
||||
"showingResults": "عرض {count} النتائج ",
|
||||
"noResults": "لم يتم العثور على أي أيقونات",
|
||||
"tryDifferentSearch": "جرّب مصطلح بحث مختلف",
|
||||
"copiedToClipboard": "نسخ \"{name}\" إلى الحافظة!",
|
||||
"copyFailed": "فشل النسخ إلى الحافظة",
|
||||
"custom": "مخصص",
|
||||
"useCustomIcon": "استخدام أيقونة مخصصة: {name}",
|
||||
"iconLibraryUrl": "fonts.google.com/icons",
|
||||
@@ -869,7 +866,8 @@
|
||||
"reassignPath": "إعادة تعيين المسار",
|
||||
"pathReassigned": "تم تحديث المسار بنجاح!",
|
||||
"pathReassignFailed": "فشل تحديث المسار. يرجى المحاولة مرة أخرى.",
|
||||
"incompleteResultsDetails": "عرض أقصى النتائج ({max}). حاول تضييق نطاق البحث للحصول على المزيد من النتائج تحت {max}."
|
||||
"incompleteResultsDetails": "عرض أقصى النتائج ({max}). حاول تضييق نطاق البحث للحصول على المزيد من النتائج تحت {max}.",
|
||||
"noCreateOptions": "لا توجد خيارات إنشاء متاحة"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "أداة البحث عن الملفات المكررة",
|
||||
|
||||
+19
-21
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"buttons": {
|
||||
"copySuccess": "Zkopírováno do schránky!",
|
||||
"copyFailed": "Kopírování do schránky se nezdařilo",
|
||||
"generateNewOtp": "Resetovat a vygenerovat nový kód dvoufázového ověření",
|
||||
"copyFile": "Kopírovat soubor",
|
||||
"copyToClipboard": "Kopírovat do schránky",
|
||||
@@ -33,7 +34,8 @@
|
||||
"showMore": "Zobrazit rozšířené možnosti",
|
||||
"showLess": "Skrytí pokročilých možností",
|
||||
"goToSource": "Přejít na zdrojové umístění",
|
||||
"goToItem": "Přejít na položku"
|
||||
"goToItem": "Přejít na položku",
|
||||
"copyPath": "Kopírovat cestu"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Stáhnout soubor",
|
||||
@@ -292,8 +294,6 @@
|
||||
"selectHashAlgorithm": "Výběr algoritmu hash",
|
||||
"generate": "Generovat",
|
||||
"errorGeneratingHash": "Chyba při generování hash",
|
||||
"hashCopied": "Hash zkopírovaný do schránky",
|
||||
"errorCopyingHash": "Chyba při kopírování hash",
|
||||
"token": "Token",
|
||||
"onlyOfficeId": "OnlyOffice ID",
|
||||
"firstLoadTitle": "Vítejte v aplikaci FileBrowser Quantum!",
|
||||
@@ -304,7 +304,6 @@
|
||||
"renameSuccess": "Položka byla úspěšně přejmenována!",
|
||||
"newFileSuccess": "Soubor byl úspěšně vytvořen!",
|
||||
"newDirSuccess": "Adresář byl úspěšně vytvořen!",
|
||||
"copyToClipboardFailed": "Nelze zkopírovat do schránky",
|
||||
"moveFailed": "Přesun se nezdařil",
|
||||
"operationFailed": "Operace se nezdařila",
|
||||
"partialSuccess": "Některé operace se nezdařily: {succeeded} uspěl, {failed} neuspěl.",
|
||||
@@ -319,14 +318,12 @@
|
||||
"unarchiveMessage": "Vyberte, kam se má archiv rozbalit.",
|
||||
"unarchiveDestination": "Výpis na:",
|
||||
"unarchiveSuccess": "Archiv úspěšně extrahován!",
|
||||
"downloadFailed": "Stahování po částech se nezdařilo - pokud se tato situace opakuje, zkuste zmenšit velikost části: {message}"
|
||||
"downloadFailed": "Stahování po částech se nezdařilo - pokud se tato situace opakuje, zkuste zmenšit velikost části: {message}",
|
||||
"confirmPasswordToSaveUser": "Pro uložení těchto uživatelských změn zadejte své aktuální heslo."
|
||||
},
|
||||
"search": {
|
||||
"smallerThan": "Menší než:",
|
||||
"largerThan": "Větší než:",
|
||||
"helpText1": "Vyhledávání probíhá po každém napsaném znaku (minimálně 3 znaky pro hledaný výraz).",
|
||||
"helpText2": "Index: Vyhledávání využívá index, který se automaticky aktualizuje v nastaveném intervalu (výchozí: 5 minut). Hledání ihned po spuštění programu může vracet neúplné výsledky.",
|
||||
"noResults": "V indexovaném vyhledávání nebyly nalezeny žádné výsledky.",
|
||||
"onlyFolders": "Pouze složky",
|
||||
"onlyFiles": "Pouze soubory",
|
||||
"showOptions": "Zobrazit možnosti",
|
||||
@@ -336,7 +333,10 @@
|
||||
"showPreviewImages": "Zobrazit náhledové obrázky",
|
||||
"showPreviewImagesDescription": "Zobrazení miniaturních náhledů výsledků vyhledávání, pokud jsou k dispozici, a zobrazení větších ikon u všech výsledků.",
|
||||
"olderThanDate": "Starší než:",
|
||||
"newerThanDate": "Novější než:"
|
||||
"newerThanDate": "Novější než:",
|
||||
"helpText2": "Výsledky vyhledávání jsou založeny na indexovaných souborech, takže nově přidané soubory se mohou zobrazit až po jejich indexování.",
|
||||
"noResults": "Nebyly nalezeny žádné výsledky.",
|
||||
"helpText1": "Vyhledávání probíhá na základě každého zadaného znaku ({minSearchLength} minimálně znaků)."
|
||||
},
|
||||
"profileSettings": {
|
||||
"preferEditorForMarkdown": "Upřednostnit editor pro Markdown soubory",
|
||||
@@ -345,8 +345,6 @@
|
||||
"deleteAfterArchiveDescription": "Pokud je povoleno, po úspěšném vytvoření/extrahování archivů dojde ke smazání zdrojových souborů",
|
||||
"hideFilesInTree": "Skrýt soubory ve stromové navigaci",
|
||||
"hideFilesInTreeDescription": "Pokud je povoleno, v postranním stromovém navigačním panelu se zobrazují pouze složky",
|
||||
"previewModelsDescription": "Zobrazit náhledy miniatur pro 3D modely, pokud je zakázáno, zobrazí se obecná ikona",
|
||||
"previewAudiosDescription": "Zobrazit náhledy miniatur pro zvuky, pokud je zakázáno, zobrazí se obecná ikona",
|
||||
"setDateFormat": "Nastavit formát data",
|
||||
"showHiddenFiles": "Zobrazit skryté soubory",
|
||||
"showQuickDownload": "Zobrazit ikonu rychlého stahování",
|
||||
@@ -364,8 +362,6 @@
|
||||
"editorQuickSave": "Zobrazení tlačítka rychlého uložení v editoru",
|
||||
"showHiddenFilesDescription": "Zobrazení skrytých souborů ve výpisu (např. .thumbnails, ProgramData), ale jsou stále ručně přístupné, pokud jsou nakonfigurovány k indexování.",
|
||||
"showQuickDownloadDescription": "Zobrazení ikony stahování pro rychlý přístup ke každé položce souboru, na kterou lze kliknout a okamžitě ji stáhnout.",
|
||||
"previewImagesDescription": "Zobrazení náhledů miniatur obrázků, pokud je zakázáno, zobrazí se obecná ikona.",
|
||||
"previewVideosDescription": "Zobrazení náhledů miniatur videí, pokud je vypnuto, zobrazí se obecná ikona.",
|
||||
"editOfficeDescription": "Pokud je povolena pouze funkce office, prohlížeč/editor souborů bude zakázán pro všechny přípony souborů související s kancelářskými soubory.",
|
||||
"disableUpdateNotificationsDescription": "Zakázat banner s upozorněním na aktualizaci, který se zobrazuje uživatelům správce na postranním panelu.",
|
||||
"disableHideSidebarDescription": "Je-li tato možnost povolena, postranní panel se při náhledu nebo úpravě souborů automaticky neskryje.",
|
||||
@@ -402,7 +398,10 @@
|
||||
"popupPreviewDescription": "Při najetí na položky s miniaturami se nad uživatelským rozhraním zobrazí překryvné vyskakovací okno.",
|
||||
"thumbnailOptions": "Možnosti miniatur",
|
||||
"showThumbnails": "Zobrazení miniatur podporovaných položek",
|
||||
"showThumbnailsDescription": "Pokud je tato možnost povolena, zobrazují se ve výpisu náhledy položek, které je podporují. Vypnutím se všechny miniatury skryjí."
|
||||
"showThumbnailsDescription": "Pokud je tato možnost povolena, zobrazují se ve výpisu náhledy položek, které je podporují. Vypnutím se všechny miniatury skryjí.",
|
||||
"showCopyPath": "Zobrazit kopírování cesty v kontextové nabídce",
|
||||
"showCopyPathDescription": "Zobrazení akce Kopírovat cestu v kontextové nabídce souborů a složek.",
|
||||
"previewDescription": "Zobrazit náhledy miniatur pro {filetype}, pokud je zakázáno, zobrazí se obecná ikona."
|
||||
},
|
||||
"settings": {
|
||||
"enterPassword": "Zadejte heslo",
|
||||
@@ -437,7 +436,6 @@
|
||||
"tusUploadsRetryCount": "Počet pokusů o opětovné nahrání části při chybě.",
|
||||
"userHomeBasePath": "Základní cesta pro domovské adresáře uživatelů",
|
||||
"userScopeGenerationPlaceholder": "Rozsah bude vygenerován automaticky",
|
||||
"createUserHomeDirectory": "Vytvořit domovský adresář uživatele",
|
||||
"customStylesheet": "Vlastní styl",
|
||||
"defaultUserDescription": "Toto jsou výchozí nastavení pro nové uživatele.",
|
||||
"disableExternalLinks": "Zakázat externí odkazy (kromě dokumentace)",
|
||||
@@ -608,7 +606,8 @@
|
||||
"enabledSuccessfully": "OTP úspěšně aktivováno",
|
||||
"verificationFailed": "Ověření se nezdařilo",
|
||||
"verificationSucceed": "Ověření proběhlo úspěšně",
|
||||
"generationFailed": "Generování selhalo"
|
||||
"generationFailed": "Generování selhalo",
|
||||
"passwordRequiredForGeneration": "Pro vygenerování ověřovacího kódu je nutné vaše heslo."
|
||||
},
|
||||
"fileLoading": {
|
||||
"title": "Nahrávání a stahování",
|
||||
@@ -665,7 +664,6 @@
|
||||
"results": "Výsledky{suffix}",
|
||||
"users": "Uživatelé{suffix}",
|
||||
"photos": "Fotografie",
|
||||
"audios": "Zvuky{suffix}",
|
||||
"videos": "Videa{suffix}",
|
||||
"models": "3D modely",
|
||||
"documents": "Dokumenty{suffix}",
|
||||
@@ -751,7 +749,8 @@
|
||||
"format": "Formát{suffix}",
|
||||
"office": "Kancelář{suffix}",
|
||||
"show": "Zobrazit{suffix}",
|
||||
"divider": "Rozdělovač{suffix}"
|
||||
"divider": "Rozdělovač{suffix}",
|
||||
"audio": "Audio{suffix}"
|
||||
},
|
||||
"access": {
|
||||
"rules": "Pravidla přístupu",
|
||||
@@ -844,8 +843,6 @@
|
||||
"showingResults": "Zobrazují se výsledky {count} ",
|
||||
"noResults": "Nebyly nalezeny žádné ikony",
|
||||
"tryDifferentSearch": "Zkuste použít jiný vyhledávací výraz",
|
||||
"copiedToClipboard": "Zkopírováno '{name}' do schránky!",
|
||||
"copyFailed": "Nepodařilo se zkopírovat do schránky",
|
||||
"custom": "Vlastní",
|
||||
"useCustomIcon": "Použití vlastní ikony: {name}",
|
||||
"iconLibraryUrl": "fonts.google.com/icons",
|
||||
@@ -869,7 +866,8 @@
|
||||
"reassignPath": "Změna přiřazení cesty",
|
||||
"pathReassigned": "Cesta byla úspěšně aktualizována!",
|
||||
"pathReassignFailed": "Nepodařilo se aktualizovat cestu. Zkuste to prosím znovu.",
|
||||
"incompleteResultsDetails": "Zobrazeno max. výsledků ({max}). Zkuste zúžit hledání a získat více výsledků pod {max}."
|
||||
"incompleteResultsDetails": "Zobrazeno max. výsledků ({max}). Zkuste zúžit hledání a získat více výsledků pod {max}.",
|
||||
"noCreateOptions": "Nejsou k dispozici žádné možnosti vytváření"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Vyhledávač duplicitních souborů",
|
||||
|
||||
+19
-21
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"buttons": {
|
||||
"copySuccess": "In die Zwischenablage kopiert!",
|
||||
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen",
|
||||
"copyFile": "Datei kopieren",
|
||||
"copyToClipboard": "In Zwischenablage kopieren",
|
||||
"moveFile": "Datei verschieben",
|
||||
@@ -33,7 +34,8 @@
|
||||
"showMore": "Erweiterte Optionen anzeigen",
|
||||
"showLess": "Erweiterte Optionen ausblenden",
|
||||
"goToSource": "Zum Quellort gehen",
|
||||
"goToItem": "Weiter zum Artikel"
|
||||
"goToItem": "Weiter zum Artikel",
|
||||
"copyPath": "Pfad kopieren"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Datei herunterladen",
|
||||
@@ -214,8 +216,6 @@
|
||||
"selectHashAlgorithm": "Wählen Sie einen Hash-Algorithmus",
|
||||
"generate": "Erzeugen Sie",
|
||||
"errorGeneratingHash": "Fehler bei der Hash-Erzeugung",
|
||||
"hashCopied": "Hash in Zwischenablage kopiert",
|
||||
"errorCopyingHash": "Fehler beim Kopieren des Hashes",
|
||||
"token": "Token",
|
||||
"onlyOfficeId": "OnlyOffice ID",
|
||||
"firstLoadTitle": "Willkommen bei FileBrowser Quantum!",
|
||||
@@ -226,7 +226,6 @@
|
||||
"renameSuccess": "Artikel erfolgreich umbenannt!",
|
||||
"newFileSuccess": "Datei erfolgreich erstellt!",
|
||||
"newDirSuccess": "Verzeichnis erfolgreich erstellt!",
|
||||
"copyToClipboardFailed": "Kopieren in die Zwischenablage nicht möglich",
|
||||
"moveFailed": "Verschieben fehlgeschlagen",
|
||||
"operationFailed": "Operation fehlgeschlagen",
|
||||
"partialSuccess": "Einige Operationen sind fehlgeschlagen: {succeeded} erfolgreich, {failed} fehlgeschlagen",
|
||||
@@ -241,14 +240,12 @@
|
||||
"unarchiveMessage": "Wählen Sie, wohin das Archiv entpackt werden soll.",
|
||||
"unarchiveDestination": "Auszug zu:",
|
||||
"unarchiveSuccess": "Archiv erfolgreich entpackt!",
|
||||
"downloadFailed": "Chunked Download fehlgeschlagen - falls sich dies wiederholt, versuchen Sie, die Chunk-Größe zu verringern: {message}"
|
||||
"downloadFailed": "Chunked Download fehlgeschlagen - falls sich dies wiederholt, versuchen Sie, die Chunk-Größe zu verringern: {message}",
|
||||
"confirmPasswordToSaveUser": "Geben Sie Ihr aktuelles Kennwort ein, um die Benutzeränderungen zu speichern."
|
||||
},
|
||||
"search": {
|
||||
"smallerThan": "Kleiner als:",
|
||||
"largerThan": "Größer als:",
|
||||
"helpText1": "Die Suche erfolgt bei jedem Zeichen, das Sie eingeben (mindestens 3 Zeichen für Suchbegriffe).",
|
||||
"helpText2": "Der Index: Die Suche verwendet den Index, der automatisch in dem konfigurierten Intervall aktualisiert wird (Standard: 5 Minuten). Eine Suche direkt nach dem Start des Programms kann zu unvollständigen Ergebnissen führen.",
|
||||
"noResults": "Keine Ergebnisse in der indizierten Suche gefunden.",
|
||||
"onlyFolders": "Nur Ordner",
|
||||
"onlyFiles": "Nur Dateien",
|
||||
"showOptions": "Optionen anzeigen",
|
||||
@@ -258,7 +255,10 @@
|
||||
"showPreviewImages": "Vorschaubilder anzeigen",
|
||||
"showPreviewImagesDescription": "Zeigen Sie eine Miniaturvorschau für Suchergebnisse an, wenn verfügbar, und zeigen Sie größere Symbole für alle Ergebnisse an.",
|
||||
"olderThanDate": "Älter als:",
|
||||
"newerThanDate": "Neuere als:"
|
||||
"newerThanDate": "Neuere als:",
|
||||
"helpText2": "Die Suchergebnisse basieren auf indizierten Dateien, so dass neu hinzugefügte Dateien möglicherweise nicht angezeigt werden, bis sie indiziert sind.",
|
||||
"noResults": "Keine Ergebnisse gefunden.",
|
||||
"helpText1": "Die Suche erfolgt bei jedem Zeichen, das Sie eingeben ({minSearchLength} Zeichen Minimum)."
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "Administrator",
|
||||
@@ -327,7 +327,6 @@
|
||||
"UserManagement": "Benutzerverwaltung",
|
||||
"userHomeBasePath": "Basispfad für Benutzer-Home-Verzeichnisse",
|
||||
"userScopeGenerationPlaceholder": "Der Bereich wird automatisch generiert",
|
||||
"createUserHomeDirectory": "Benutzer-Home-Verzeichnis erstellen",
|
||||
"loginMethodDescription": "erlaubte Anmeldemethode für den Benutzer.",
|
||||
"userNotAdmin": "Benutzer ist kein Administrator",
|
||||
"enterPassword": "Passwort eingeben",
|
||||
@@ -542,7 +541,8 @@
|
||||
"enabledSuccessfully": "OTP erfolgreich aktiviert",
|
||||
"verificationFailed": "Verifizierung fehlgeschlagen",
|
||||
"verificationSucceed": "Überprüfung Erfolgreich",
|
||||
"generationFailed": "Generation gescheitert"
|
||||
"generationFailed": "Generation gescheitert",
|
||||
"passwordRequiredForGeneration": "Ihr Passwort ist erforderlich, um einen Authentifizierungscode zu generieren."
|
||||
},
|
||||
"general": {
|
||||
"name": "Name",
|
||||
@@ -579,7 +579,6 @@
|
||||
"results": "Ergebnisse{suffix}",
|
||||
"users": "Benutzer{suffix}",
|
||||
"photos": "Fotos",
|
||||
"audios": "Audios{suffix}",
|
||||
"videos": "Videos{suffix}",
|
||||
"models": "3D-Modelle",
|
||||
"documents": "Dokumente{suffix}",
|
||||
@@ -665,7 +664,8 @@
|
||||
"format": "Format{suffix}",
|
||||
"office": "Büro{suffix}",
|
||||
"show": "anzeigen{suffix}",
|
||||
"divider": "Teiler{suffix}"
|
||||
"divider": "Teiler{suffix}",
|
||||
"audio": "Audio{suffix}"
|
||||
},
|
||||
"access": {
|
||||
"rules": "Zugangsregeln",
|
||||
@@ -717,15 +717,11 @@
|
||||
"deleteAfterArchiveDescription": "Wenn aktiviert, werden die Quelldateien nach erfolgreicher Erstellung/Extraktion von Archiven gelöscht",
|
||||
"hideFilesInTree": "Dateien in der Baumnavigation ausblenden",
|
||||
"hideFilesInTreeDescription": "Wenn aktiviert, werden in der Seitenleisten-Baumnavigation nur Ordner angezeigt",
|
||||
"previewModelsDescription": "Vorschaubilder für 3D-Modelle anzeigen, bei Deaktivierung wird ein generisches Symbol angezeigt",
|
||||
"previewAudiosDescription": "Vorschaubilder für Audiodateien anzeigen, bei Deaktivierung wird ein generisches Symbol angezeigt",
|
||||
"setDateFormat": "Genaues Datum statt relativer Zeitangaben verwenden",
|
||||
"showHiddenFiles": "Versteckte Dateien anzeigen",
|
||||
"showHiddenFilesDescription": "Versteckte Dateien in der Auflistung anzeigen (z.B. .thumbnails, ProgramData), aber sie sind immer noch manuell zugänglich, wenn sie für die Indizierung konfiguriert sind.",
|
||||
"showQuickDownload": "Download-Symbol für schnellen Zugriff anzeigen",
|
||||
"showQuickDownloadDescription": "Zeigen Sie ein Download-Symbol für den schnellen Zugriff auf jedes Dateielement an, das zum sofortigen Herunterladen angeklickt werden kann.",
|
||||
"previewImagesDescription": "Thumbnail-Vorschau für Bilder anzeigen, bei Deaktivierung wird ein allgemeines Symbol angezeigt.",
|
||||
"previewVideosDescription": "Thumbnail-Vorschau für Videos anzeigen, bei Deaktivierung wird ein allgemeines Symbol angezeigt.",
|
||||
"editOffice": "Anzeigen und Bearbeiten von Office-Dateien mit OnlyOffice",
|
||||
"editOfficeDescription": "Wenn nur Office aktiviert ist, wird der Dateibetrachter/-editor für alle Dateierweiterungen deaktiviert, die mit Office-Dateien zusammenhängen.",
|
||||
"filePreviewOptions": "Optionen für die Dateivorschau",
|
||||
@@ -774,7 +770,10 @@
|
||||
"popupPreviewDescription": "Ein Overlay-Pop-up-Fenster wird über der Benutzeroberfläche angezeigt, wenn der Mauszeiger über Elemente mit Miniaturansichten bewegt wird",
|
||||
"thumbnailOptions": "Thumbnail-Optionen",
|
||||
"showThumbnails": "Miniaturansichten für unterstützte Artikel anzeigen",
|
||||
"showThumbnailsDescription": "Wenn diese Option aktiviert ist, werden Miniaturansichten in der Auflistung für Artikel angezeigt, die sie unterstützen. Schalten Sie die Option aus, um alle Miniaturansichten auszublenden."
|
||||
"showThumbnailsDescription": "Wenn diese Option aktiviert ist, werden Miniaturansichten in der Auflistung für Artikel angezeigt, die sie unterstützen. Schalten Sie die Option aus, um alle Miniaturansichten auszublenden.",
|
||||
"showCopyPath": "Pfad kopieren im Kontextmenü anzeigen",
|
||||
"showCopyPathDescription": "Zeigen Sie eine Aktion \"Pfad kopieren\" im Kontextmenü von Dateien und Ordnern an.",
|
||||
"previewDescription": "Vorschaubilder für {filetype} anzeigen, bei Deaktivierung wird ein allgemeines Symbol angezeigt."
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Der Editor konnte nicht initialisiert werden. Bitte neu laden.",
|
||||
@@ -844,8 +843,6 @@
|
||||
"showingResults": "{count} Ergebnisse anzeigen",
|
||||
"noResults": "Keine Icons gefunden",
|
||||
"tryDifferentSearch": "Versuchen Sie einen anderen Suchbegriff",
|
||||
"copiedToClipboard": "Kopiert '{name}' in die Zwischenablage!",
|
||||
"copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen",
|
||||
"custom": "Benutzerdefiniert",
|
||||
"useCustomIcon": "Benutzerdefiniertes Symbol verwenden: {name}",
|
||||
"iconLibraryUrl": "fonts.google.com/icons",
|
||||
@@ -869,7 +866,8 @@
|
||||
"reassignPath": "Pfad neu zuweisen",
|
||||
"pathReassigned": "Pfad erfolgreich aktualisiert!",
|
||||
"pathReassignFailed": "Der Pfad konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.",
|
||||
"incompleteResultsDetails": "Zeigt max Ergebnisse ({max}). Versuchen Sie, Ihre Suche einzugrenzen, um mehr Ergebnisse unter {max} zu erhalten."
|
||||
"incompleteResultsDetails": "Zeigt max Ergebnisse ({max}). Versuchen Sie, Ihre Suche einzugrenzen, um mehr Ergebnisse unter {max} zu erhalten.",
|
||||
"noCreateOptions": "Es sind keine Erstellungsoptionen verfügbar"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Duplicate File Finder",
|
||||
|
||||
+19
-21
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"buttons": {
|
||||
"copySuccess": "Αντιγράφηκε στο πρόχειρο!",
|
||||
"copyFailed": "Απέτυχε η αντιγραφή στο πρόχειρο",
|
||||
"copyFile": "Αντιγραφή αρχείου",
|
||||
"copyToClipboard": "Αντιγραφή στο πρόχειρο",
|
||||
"copyDownloadLinkToClipboard": "Αντιγραφή συνδέσμου λήψης στο πρόχειρο",
|
||||
@@ -33,7 +34,8 @@
|
||||
"showMore": "Εμφάνιση προηγμένων επιλογών",
|
||||
"showLess": "Απόκρυψη προηγμένων επιλογών",
|
||||
"goToSource": "Πηγαίνετε στην τοποθεσία της πηγής",
|
||||
"goToItem": "Πηγαίνετε στο στοιχείο"
|
||||
"goToItem": "Πηγαίνετε στο στοιχείο",
|
||||
"copyPath": "Αντιγραφή διαδρομής"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Λήψη αρχείου",
|
||||
@@ -217,8 +219,6 @@
|
||||
"selectHashAlgorithm": "Επιλέξτε έναν αλγόριθμο κατακερματισμού",
|
||||
"generate": "Δημιουργία",
|
||||
"errorGeneratingHash": "Σφάλμα δημιουργίας hash",
|
||||
"hashCopied": "Αντιγραφή κατακερματισμού στο πρόχειρο",
|
||||
"errorCopyingHash": "Σφάλμα αντιγραφής hash",
|
||||
"token": "Token",
|
||||
"onlyOfficeId": "ΜόνοΤαυτότητα γραφείου",
|
||||
"firstLoadTitle": "Καλώς ήρθατε στο FileBrowser Quantum!",
|
||||
@@ -229,7 +229,6 @@
|
||||
"renameSuccess": "Το στοιχείο μετονομάστηκε επιτυχώς!",
|
||||
"newFileSuccess": "Το αρχείο δημιουργήθηκε με επιτυχία!",
|
||||
"newDirSuccess": "Ο κατάλογος δημιουργήθηκε με επιτυχία!",
|
||||
"copyToClipboardFailed": "Αδυναμία αντιγραφής στο πρόχειρο",
|
||||
"moveFailed": "Η μετακίνηση απέτυχε",
|
||||
"operationFailed": "Η λειτουργία απέτυχε",
|
||||
"partialSuccess": "Ορισμένες λειτουργίες απέτυχαν: {succeeded} πέτυχε, {failed} απέτυχε",
|
||||
@@ -244,14 +243,12 @@
|
||||
"unarchiveMessage": "Επιλέξτε πού θα εξαγάγετε το αρχείο.",
|
||||
"unarchiveDestination": "Απόσπασμα προς:",
|
||||
"unarchiveSuccess": "Το αρχείο εξήχθη με επιτυχία!",
|
||||
"downloadFailed": "Αποτυχημένη λήψη σε τεμάχια - αν αυτό επαναληφθεί, δοκιμάστε να μειώσετε το μέγεθος των τεμαχίων: {message}"
|
||||
"downloadFailed": "Αποτυχημένη λήψη σε τεμάχια - αν αυτό επαναληφθεί, δοκιμάστε να μειώσετε το μέγεθος των τεμαχίων: {message}",
|
||||
"confirmPasswordToSaveUser": "Εισάγετε τον τρέχοντα κωδικό πρόσβασης για να αποθηκεύσετε αυτές τις αλλαγές χρήστη."
|
||||
},
|
||||
"search": {
|
||||
"smallerThan": "Μικρότερο από:",
|
||||
"largerThan": "Μεγαλύτερο από:",
|
||||
"helpText1": "Η αναζήτηση πραγματοποιείται σε κάθε χαρακτήρα που πληκτρολογείτε (ελάχιστο όριο 3 χαρακτήρων για τους όρους αναζήτησης).",
|
||||
"helpText2": "Ο δείκτης: Η αναζήτηση χρησιμοποιεί το ευρετήριο το οποίο ενημερώνεται αυτόματα στο ρυθμισμένο διάστημα (προεπιλογή: 5 λεπτά). Η αναζήτηση όταν το πρόγραμμα έχει μόλις ξεκινήσει μπορεί να οδηγήσει σε ελλιπή αποτελέσματα.",
|
||||
"noResults": "Δεν βρέθηκαν αποτελέσματα στην ευρετηριασμένη αναζήτηση.",
|
||||
"onlyFolders": "Μόνο φάκελοι",
|
||||
"onlyFiles": "Μόνο αρχεία",
|
||||
"showOptions": "Εμφάνιση επιλογών",
|
||||
@@ -261,7 +258,10 @@
|
||||
"showPreviewImages": "Εμφάνιση εικόνων προεπισκόπησης",
|
||||
"showPreviewImagesDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για τα αποτελέσματα αναζήτησης, όταν είναι διαθέσιμα, και εμφάνιση μεγαλύτερων εικονιδίων για όλα τα αποτελέσματα.",
|
||||
"olderThanDate": "Μεγαλύτερη από:",
|
||||
"newerThanDate": "Νεότερο από:"
|
||||
"newerThanDate": "Νεότερο από:",
|
||||
"helpText2": "Τα αποτελέσματα της αναζήτησης βασίζονται στα ευρετηριασμένα αρχεία, οπότε τα πρόσφατα προστιθέμενα αρχεία ενδέχεται να μην εμφανίζονται μέχρι να ευρετηριαστούν.",
|
||||
"noResults": "Δεν βρέθηκαν αποτελέσματα.",
|
||||
"helpText1": "Η αναζήτηση πραγματοποιείται σε κάθε χαρακτήρα που πληκτρολογείτε ({minSearchLength} ελάχιστος χαρακτήρας)."
|
||||
},
|
||||
"settings": {
|
||||
"administrator": "Διαχειριστής",
|
||||
@@ -282,7 +282,6 @@
|
||||
"tusUploadsRetryCount": "Αριθμός επαναληπτικών δοκιμών που θα πραγματοποιηθούν αν αποτύχει η μεταφόρτωση ενός τμήματος.",
|
||||
"userHomeBasePath": "Βασική διαδρομή αρχείων για τους φακέλους των χρηστών",
|
||||
"userScopeGenerationPlaceholder": "Η εμβέλεια εφαρμογής θα δημιουργηθεί αυτόματα",
|
||||
"createUserHomeDirectory": "Δημιουργία φακέλου χρήστη",
|
||||
"customStylesheet": "Προσαρμοσμένο στυλ εμφάνισης (stylesheet)",
|
||||
"defaultUserDescription": "Αυτές είναι οι προεπιλεγμένες ρυθμίσεις για νέους χρήστες.",
|
||||
"disableExternalLinks": "Απενεργοποίηση εξωτερικών συνδέσμων (εκτός από συνδέσμους προς τις οδηγίες χρήσης)",
|
||||
@@ -542,7 +541,8 @@
|
||||
"enabledSuccessfully": "Το OTP ενεργοποιήθηκε με επιτυχία",
|
||||
"verificationFailed": "Η επαλήθευση απέτυχε",
|
||||
"verificationSucceed": "Επαλήθευση Succeed",
|
||||
"generationFailed": "Γενιά που απέτυχε"
|
||||
"generationFailed": "Γενιά που απέτυχε",
|
||||
"passwordRequiredForGeneration": "Ο κωδικός πρόσβασής σας απαιτείται για τη δημιουργία ενός κωδικού ελέγχου ταυτότητας."
|
||||
},
|
||||
"general": {
|
||||
"name": "Όνομα",
|
||||
@@ -579,7 +579,6 @@
|
||||
"results": "Αποτελέσματα{suffix}",
|
||||
"users": "Χρήστες{suffix}",
|
||||
"photos": "Φωτογραφίες",
|
||||
"audios": "Ήχοι{suffix}",
|
||||
"videos": "Βίντεο{suffix}",
|
||||
"models": "Τρισδιάστατα μοντέλα",
|
||||
"documents": "Έγγραφα{suffix}",
|
||||
@@ -665,7 +664,8 @@
|
||||
"format": "Μορφή{suffix}",
|
||||
"office": "Γραφείο{suffix}",
|
||||
"show": "Εμφάνιση{suffix}",
|
||||
"divider": "Διαχωριστικό{suffix}"
|
||||
"divider": "Διαχωριστικό{suffix}",
|
||||
"audio": "Ήχος{suffix}"
|
||||
},
|
||||
"access": {
|
||||
"rules": "Κανόνες πρόσβασης",
|
||||
@@ -717,15 +717,11 @@
|
||||
"deleteAfterArchiveDescription": "Όταν ενεργοποιηθεί, θα διαγραφούν τα αρχεία προέλευσης μετά την επιτυχή δημιουργία/εξαγωγή αρχείων",
|
||||
"hideFilesInTree": "Απόκρυψη αρχείων στην πλοήγηση δέντρου",
|
||||
"hideFilesInTreeDescription": "Όταν ενεργοποιηθεί, μόνο οι φάκελοι εμφανίζονται στην πλευρική δενδρική πλοήγηση",
|
||||
"previewModelsDescription": "Εμφάνιση μικρογραφιών για τρισδιάστατα μοντέλα, θα εμφανιστεί γενικό εικονίδιο εάν είναι απενεργοποιημένο",
|
||||
"previewAudiosDescription": "Εμφάνιση μικρογραφιών για ήχους, θα εμφανιστεί γενικό εικονίδιο εάν είναι απενεργοποιημένο",
|
||||
"setDateFormat": "Ορισμός ακριβούς μορφής ημερομηνίας",
|
||||
"showHiddenFiles": "Εμφάνιση κρυφών αρχείων",
|
||||
"showHiddenFilesDescription": "Εμφάνιση κρυμμένων αρχείων στην καταχώριση (π.χ. .thumbnails, ProgramData), αλλά εξακολουθούν να είναι προσβάσιμα χειροκίνητα, εάν έχουν ρυθμιστεί για ευρετηρίαση.",
|
||||
"showQuickDownload": "Εμφάνιση εικονιδίου λήψης για γρήγορη πρόσβαση",
|
||||
"showQuickDownloadDescription": "Εμφάνιση εικονιδίου λήψης για γρήγορη πρόσβαση σε κάθε στοιχείο αρχείου που μπορεί να πατηθεί για άμεση λήψη.",
|
||||
"previewImagesDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για εικόνες, θα εμφανίζει γενικό εικονίδιο αν είναι απενεργοποιημένο.",
|
||||
"previewVideosDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για βίντεο, θα εμφανίζει γενικό εικονίδιο αν είναι απενεργοποιημένο.",
|
||||
"editOffice": "Προβολή και επεξεργασία αρχείων γραφείου με το OnlyOffice",
|
||||
"editOfficeDescription": "Όταν είναι ενεργοποιημένο μόνο το office, η προβολή/επεξεργασία αρχείων θα είναι απενεργοποιημένη για όλες τις επεκτάσεις αρχείων που σχετίζονται με αρχεία office.",
|
||||
"filePreviewOptions": "Επιλογές προεπισκόπησης αρχείων",
|
||||
@@ -774,7 +770,10 @@
|
||||
"popupPreviewDescription": "Ένα αναδυόμενο παράθυρο επικάλυψης θα εμφανίζεται πάνω από το UI όταν περνάτε το ποντίκι πάνω από αντικείμενα με μικρογραφίες.",
|
||||
"thumbnailOptions": "Επιλογές μικρογραφιών",
|
||||
"showThumbnails": "Εμφάνιση μικρογραφιών για υποστηριζόμενα στοιχεία",
|
||||
"showThumbnailsDescription": "Όταν είναι ενεργοποιημένη, οι μικρογραφίες εμφανίζονται στην καταχώριση για τα στοιχεία που τις υποστηρίζουν. Απενεργοποιήστε την επιλογή για να αποκρύψετε όλες τις μικρογραφίες."
|
||||
"showThumbnailsDescription": "Όταν είναι ενεργοποιημένη, οι μικρογραφίες εμφανίζονται στην καταχώριση για τα στοιχεία που τις υποστηρίζουν. Απενεργοποιήστε την επιλογή για να αποκρύψετε όλες τις μικρογραφίες.",
|
||||
"showCopyPath": "Εμφάνιση της διαδρομής αντιγραφής στο μενού περιβάλλοντος",
|
||||
"showCopyPathDescription": "Εμφάνιση της ενέργειας Αντιγραφή διαδρομής στο μενού περιβάλλοντος αρχείων και φακέλων.",
|
||||
"previewDescription": "Εμφάνιση προεπισκοπήσεων μικρογραφιών για το {filetype}, θα εμφανίζει γενικό εικονίδιο εάν είναι απενεργοποιημένο."
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "Απέτυχε η αρχικοποίηση του συντάκτη. Παρακαλούμε επαναφορτώστε.",
|
||||
@@ -844,8 +843,6 @@
|
||||
"showingResults": "Εμφάνιση {count} αποτελεσμάτων",
|
||||
"noResults": "Δεν βρέθηκαν εικονίδια",
|
||||
"tryDifferentSearch": "Δοκιμάστε έναν διαφορετικό όρο αναζήτησης",
|
||||
"copiedToClipboard": "Αντιγραφή του '{name}' στο πρόχειρο!",
|
||||
"copyFailed": "Απέτυχε η αντιγραφή στο πρόχειρο",
|
||||
"custom": "Προσαρμοσμένο",
|
||||
"useCustomIcon": "Χρήση προσαρμοσμένου εικονιδίου: {name}",
|
||||
"iconLibraryUrl": "fonts.google.com/icons",
|
||||
@@ -869,7 +866,8 @@
|
||||
"reassignPath": "Επαναπροσδιορισμός διαδρομής",
|
||||
"pathReassigned": "Η διαδρομή ενημερώθηκε επιτυχώς!",
|
||||
"pathReassignFailed": "Απέτυχε η ενημέρωση της διαδρομής. Προσπαθήστε ξανά.",
|
||||
"incompleteResultsDetails": "Εμφάνιση μέγιστων αποτελεσμάτων ({max}). Προσπαθήστε να περιορίσετε την αναζήτησή σας για να πάρετε περισσότερα αποτελέσματα κάτω από {max}."
|
||||
"incompleteResultsDetails": "Εμφάνιση μέγιστων αποτελεσμάτων ({max}). Προσπαθήστε να περιορίσετε την αναζήτησή σας για να πάρετε περισσότερα αποτελέσματα κάτω από {max}.",
|
||||
"noCreateOptions": "Δεν υπάρχουν διαθέσιμες επιλογές δημιουργίας"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Αναζήτηση διπλών αρχείων",
|
||||
|
||||
+23
-25
@@ -5,7 +5,8 @@
|
||||
"reassignPath": "Reassign Path",
|
||||
"pathReassigned": "Path successfully updated!",
|
||||
"pathReassignFailed": "Failed to update path. Please try again.",
|
||||
"incompleteResultsDetails": "Showing max results ({max}). Try narrowing your search to get more results under {max}."
|
||||
"incompleteResultsDetails": "Showing max results ({max}). Try narrowing your search to get more results under {max}.",
|
||||
"noCreateOptions": "No create options are available"
|
||||
},
|
||||
"general": {
|
||||
"divider": "Divider{suffix}",
|
||||
@@ -57,7 +58,7 @@
|
||||
"scopes": "Scopes{suffix}",
|
||||
"commands": "Commands{suffix}",
|
||||
"photos": "Photos",
|
||||
"audios": "Audios{suffix}",
|
||||
"audio": "Audio{suffix}",
|
||||
"videos": "Videos{suffix}",
|
||||
"documents": "Documents{suffix}",
|
||||
"archives": "Archives{suffix}",
|
||||
@@ -132,8 +133,10 @@
|
||||
},
|
||||
"buttons": {
|
||||
"copySuccess": "Copied to clipboard!",
|
||||
"copyFailed": "Failed to copy to clipboard",
|
||||
"generateNewOtp": "Reset and generate new two-factor code",
|
||||
"copyFile": "Copy file",
|
||||
"copyPath": "Copy Path",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copyDownloadLinkToClipboard": "Copy download link to clipboard",
|
||||
"moveFile": "Move file",
|
||||
@@ -439,9 +442,6 @@
|
||||
"selectHashAlgorithm": "Select a hash algorithm",
|
||||
"generate": "Generate",
|
||||
"errorGeneratingHash": "Error generating hash",
|
||||
"hashCopied": "Hash copied to clipboard",
|
||||
"copyToClipboardFailed": "Unable to copy to clipboard",
|
||||
"errorCopyingHash": "Error copying hash",
|
||||
"token": "Token",
|
||||
"onlyOfficeId": "OnlyOffice ID",
|
||||
"firstLoadTitle": "Welcome to FileBrowser Quantum!",
|
||||
@@ -450,7 +450,8 @@
|
||||
"halloweenBody": "The Halloween theme has been applied. You can disable it if you don't like it, or permanently disable it in profile settings by selecting any other theme.",
|
||||
"moveFailed": "Move failed",
|
||||
"operationFailed": "Operation failed",
|
||||
"partialSuccess": "Some operations failed: {succeeded} succeeded, {failed} failed"
|
||||
"partialSuccess": "Some operations failed: {succeeded} succeeded, {failed} failed",
|
||||
"confirmPasswordToSaveUser": "Enter your current password to save these user changes."
|
||||
},
|
||||
"search": {
|
||||
"searchContext": "Search Context: {context}",
|
||||
@@ -459,15 +460,15 @@
|
||||
"showOptions": "Show Options",
|
||||
"notEnoughCharacters": "Not enough characters to search (min {minSearchLength})",
|
||||
"typeToSearch": "Start typing {minSearchLength} or more characters to begin searching.",
|
||||
"noResults": "No results found in indexed search.",
|
||||
"noResults": "No results found.",
|
||||
"smallerThan": "Smaller than:",
|
||||
"largerThan": "Larger than:",
|
||||
"olderThanDate": "Older than:",
|
||||
"newerThanDate": "Newer than:",
|
||||
"showPreviewImages": "Show preview images",
|
||||
"showPreviewImagesDescription": "Display thumbnail previews for search results when available, and show larger icons for all results.",
|
||||
"helpText1": "Search occurs on each character you type (3 character minimum for search terms).",
|
||||
"helpText2": "The index: Search utilizes the index which automatically gets updated on the configured interval (default: 5 minutes). Searching when the program has just started may result in incomplete results."
|
||||
"helpText1": "Search occurs on each character you type ({minSearchLength} character minimum).",
|
||||
"helpText2": "Search results are based on indexed files, so newly added files may not appear until they are indexed."
|
||||
},
|
||||
"profileSettings": {
|
||||
"preferEditorForMarkdown": "Prefer editor first for Markdown files",
|
||||
@@ -491,10 +492,7 @@
|
||||
"showHiddenFilesDescription": "Show hidden files in listing (e.g. .thumbnails, ProgramData), but they are still manually accessible if configured to be indexed.",
|
||||
"showQuickDownload": "Show download icon for quick access",
|
||||
"showQuickDownloadDescription": "Show download icon for quick access on each file item that can be clicked to download instantly.",
|
||||
"previewImagesDescription": "Show thumbnail previews for images, will show generic icon if disabled.",
|
||||
"previewVideosDescription": "Show thumbnail previews for videos, will show generic icon if disabled.",
|
||||
"previewAudiosDescription": "Show thumbnail previews for audios, will show generic icon if disabled.",
|
||||
"previewModelsDescription": "Show thumbnail previews for 3D Models, will show generic icon if disabled.",
|
||||
"previewDescription": "Show thumbnail previews for {filetype}, will show generic icon if disabled.",
|
||||
"previewMotion": "Motion previews",
|
||||
"previewMotionVideosDescription": "Motion preview is the animated popup preview that cycles through the frames of the video or multiple images for a folder",
|
||||
"editOffice": "View and edit office files with OnlyOffice",
|
||||
@@ -515,6 +513,8 @@
|
||||
"disableSearchOptions": "Disable search options",
|
||||
"disableSearchOptionsDescription": "Disable the search options that show up on the sidebar.",
|
||||
"hideSidebarFileActions": "Hide file actions button in sidebar",
|
||||
"showCopyPath": "Show Copy Path in context menu",
|
||||
"showCopyPathDescription": "Show a Copy Path action in the file and folder context menu.",
|
||||
"deleteWithoutConfirming": "Delete files without confirmation prompt",
|
||||
"deleteWithoutConfirmingDescription": "Delete files without confirmation prompt. This will only work if the file is a media file.",
|
||||
"editorViewerOptions": "Editor and viewer options",
|
||||
@@ -578,7 +578,6 @@
|
||||
"tusUploadsRetryCount": "Number of retries to perform if a chunk fails to upload.",
|
||||
"userHomeBasePath": "Base path for user home directories",
|
||||
"userScopeGenerationPlaceholder": "The scope will be auto generated",
|
||||
"createUserHomeDirectory": "Create user home directory",
|
||||
"customStylesheet": "Custom Stylesheet",
|
||||
"defaultUserDescription": "These are the default settings for new users.",
|
||||
"disableExternalLinks": "Disable external links (except documentation)",
|
||||
@@ -739,7 +738,8 @@
|
||||
"enabledSuccessfully": "OTP enabled successfully",
|
||||
"verificationFailed": "Verification Failed",
|
||||
"verificationSucceed": "Verification Succeed",
|
||||
"generationFailed": "Generation Failed"
|
||||
"generationFailed": "Generation Failed",
|
||||
"passwordRequiredForGeneration": "Your password is required to generate an authenticator code."
|
||||
},
|
||||
"access": {
|
||||
"all": "Deny All",
|
||||
@@ -785,13 +785,13 @@
|
||||
"orange": "orange"
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized":"Failed to initialize the editor. Please reload.",
|
||||
"saveFailed":"Failed to save file. Please try again.",
|
||||
"saveAborted":"Save aborted. The file name does not match the active file.",
|
||||
"saveAbortedMessage":"Save operation aborted. The application's active file ({activeFile}) does not match the file you are trying to save ({tryingToSave}).",
|
||||
"syncFailed":"failed to sync state with the route for \"{filename}\" after 5 attempts. Aborting editor setup to prevent data corruption.",
|
||||
"fileSaved":"File saved!",
|
||||
"errorSavingFile":"Error saving file: {error}",
|
||||
"uninitialized": "Failed to initialize the editor. Please reload.",
|
||||
"saveFailed": "Failed to save file. Please try again.",
|
||||
"saveAborted": "Save aborted. The file name does not match the active file.",
|
||||
"saveAbortedMessage": "Save operation aborted. The application's active file ({activeFile}) does not match the file you are trying to save ({tryingToSave}).",
|
||||
"syncFailed": "failed to sync state with the route for \"{filename}\" after 5 attempts. Aborting editor setup to prevent data corruption.",
|
||||
"fileSaved": "File saved!",
|
||||
"errorSavingFile": "Error saving file: {error}",
|
||||
"lines": "{count} lines",
|
||||
"words": "{count} words",
|
||||
"chars": "{count} characters"
|
||||
@@ -854,8 +854,6 @@
|
||||
"showingResults": "Showing {count} results",
|
||||
"noResults": "No icons found",
|
||||
"tryDifferentSearch": "Try a different search term",
|
||||
"copiedToClipboard": "Copied '{name}' to clipboard!",
|
||||
"copyFailed": "Failed to copy to clipboard",
|
||||
"custom": "Custom",
|
||||
"useCustomIcon": "Use custom icon: {name}"
|
||||
},
|
||||
@@ -943,4 +941,4 @@
|
||||
"editorSuccess": "Editor Initialization - Success! All steps completed.",
|
||||
"setupError": "Setup Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-21
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"buttons": {
|
||||
"copySuccess": "¡Copiado al portapapeles!",
|
||||
"copyFailed": "Error al copiar al portapapeles",
|
||||
"copyFile": "Copiar archivo",
|
||||
"copyToClipboard": "Copiar al portapapeles",
|
||||
"moveFile": "Mover archivo",
|
||||
@@ -33,7 +34,8 @@
|
||||
"showMore": "Mostrar opciones avanzadas",
|
||||
"showLess": "Ocultar opciones avanzadas",
|
||||
"goToSource": "Ir al origen",
|
||||
"goToItem": "Abrir"
|
||||
"goToItem": "Abrir",
|
||||
"copyPath": "Copiar ruta"
|
||||
},
|
||||
"download": {
|
||||
"downloadFile": "Descargar archivo",
|
||||
@@ -214,8 +216,6 @@
|
||||
"selectHashAlgorithm": "Selecciona un algoritmo",
|
||||
"generate": "Generar",
|
||||
"errorGeneratingHash": "Hubo un error al obtener el hash",
|
||||
"hashCopied": "¡Se copio el hash al portapapeles!",
|
||||
"errorCopyingHash": "Hubo un error al copiar el hash",
|
||||
"token": "Token",
|
||||
"onlyOfficeId": "OnlyOffice ID",
|
||||
"firstLoadTitle": "¡Bienvenido a FileBrowser Quantum!",
|
||||
@@ -226,7 +226,6 @@
|
||||
"renameSuccess": "El cambio de nombre se ha realizado correctamente",
|
||||
"newFileSuccess": "¡Archivo creado con éxito!",
|
||||
"newDirSuccess": "¡Carpeta creada con éxito!",
|
||||
"copyToClipboardFailed": "Hubo un error al copiar en el portapapeles",
|
||||
"moveFailed": "Error al mover",
|
||||
"operationFailed": "Operación fallida",
|
||||
"partialSuccess": "Algunas acciones fallaron: {succeeded} se completaron, y {failed} fallaron",
|
||||
@@ -241,24 +240,25 @@
|
||||
"unarchiveMessage": "Elije dónde quieres extraer el archivo:",
|
||||
"unarchiveDestination": "Extraer a:",
|
||||
"unarchiveSuccess": "¡Archivo extraído con éxito!",
|
||||
"downloadFailed": "Fallo en la descarga por trozos. Si se repite, intente reducir el tamaño de los trozos: {message}"
|
||||
"downloadFailed": "Fallo en la descarga por trozos. Si se repite, intente reducir el tamaño de los trozos: {message}",
|
||||
"confirmPasswordToSaveUser": "Introduzca su contraseña actual para guardar estos cambios de usuario."
|
||||
},
|
||||
"search": {
|
||||
"smallerThan": "Menor que:",
|
||||
"largerThan": "Mayor que:",
|
||||
"helpText1": "La busqueda se realiza por cada letra que escribes (empezando de {minSearchLength} letras)",
|
||||
"helpText2": "El índice se actualiza automáticamente cuando es necesario.",
|
||||
"onlyFolders": "Solo carpetas",
|
||||
"onlyFiles": "Solo archivos",
|
||||
"showOptions": "Mostrar opciones",
|
||||
"noResults": "No pudimos encontrar lo que buscas...",
|
||||
"searchContext": "Buscar en: {context}",
|
||||
"notEnoughCharacters": "Escribe al menos {minSearchLength} letras para empezar a buscar",
|
||||
"typeToSearch": "Escribe {minSearchLength} letras o más para iniciar la búsqueda",
|
||||
"showPreviewImages": "Mostrar miniaturas",
|
||||
"showPreviewImagesDescription": "Mostrar miniaturas para los resultados de búsqueda - También hará los iconos más grandes.",
|
||||
"olderThanDate": "Más viejo que:",
|
||||
"newerThanDate": "Más nuevo que:"
|
||||
"newerThanDate": "Más nuevo que:",
|
||||
"helpText2": "Los resultados de la búsqueda se basan en los archivos indexados, por lo que es posible que los archivos recién añadidos no aparezcan hasta que se indexen.",
|
||||
"noResults": "No se han encontrado resultados.",
|
||||
"helpText1": "La búsqueda se produce en cada carácter que escriba ({minSearchLength} carácter mínimo)."
|
||||
},
|
||||
"settings": {
|
||||
"scopes": "Fuentes a las que el usuario tendrá acceso:",
|
||||
@@ -276,7 +276,6 @@
|
||||
"createUserDir": "Crea automaticamente una carpeta cuando se agrega un nuevo usuario",
|
||||
"userHomeBasePath": "Ruta base para la carpeta personal del usuario",
|
||||
"userScopeGenerationPlaceholder": "La carpeta se generará automáticamente",
|
||||
"createUserHomeDirectory": "Crea una carpeta personal para cada usuario",
|
||||
"customStylesheet": "Hoja de estilos personalizada",
|
||||
"defaultUserDescription": "Configuración por defecto nuevos usuarios",
|
||||
"disableExternalLinks": "Desactivar enlaces externos",
|
||||
@@ -542,7 +541,8 @@
|
||||
"enabledSuccessfully": "¡2FA activado!",
|
||||
"verificationFailed": "No se pudo autenticar",
|
||||
"verificationSucceed": "¡Verificación exitosa!",
|
||||
"generationFailed": "Hubo un error al generar el código"
|
||||
"generationFailed": "Hubo un error al generar el código",
|
||||
"passwordRequiredForGeneration": "Su contraseña es necesaria para generar un código autenticador."
|
||||
},
|
||||
"general": {
|
||||
"name": "Nombre",
|
||||
@@ -579,7 +579,6 @@
|
||||
"results": "Resultados{suffix}",
|
||||
"users": "Usuarios{suffix}",
|
||||
"photos": "Fotos",
|
||||
"audios": "Audios{suffix}",
|
||||
"videos": "Vídeos{suffix}",
|
||||
"models": "Modelos 3D",
|
||||
"documents": "Documentos{suffix}",
|
||||
@@ -665,7 +664,8 @@
|
||||
"format": "Formato{suffix}",
|
||||
"office": "Office{suffix}",
|
||||
"show": "Mostrar{suffix}",
|
||||
"divider": "Divisor{suffix}"
|
||||
"divider": "Divisor{suffix}",
|
||||
"audio": "Audio{suffix}"
|
||||
},
|
||||
"access": {
|
||||
"rules": "Reglas de acceso",
|
||||
@@ -717,8 +717,6 @@
|
||||
"deleteAfterArchiveDescription": "Cuando está activado, eliminará los archivos originales después de la creación o extracción de archivos",
|
||||
"hideFilesInTree": "Ocultar archivos en la navegación de árbol",
|
||||
"hideFilesInTreeDescription": "Cuando está activado, solo se muestrarán carpetas en la navegación del panel lateral",
|
||||
"previewModelsDescription": "Mostrar miniaturas para modelos 3D, se mostrará un icono genérico si se desactiva",
|
||||
"previewAudiosDescription": "Mostrar miniaturas para audios, se mostrará un icono genérico si se desactiva",
|
||||
"autoplayMedia": "Reproducción automática de archivos multimedia",
|
||||
"autoplayMediaDescription": "Reproduce automaticamente los archivos multimedia al abrirlos",
|
||||
"sidebarOptions": "Opciones del panel lateral",
|
||||
@@ -742,8 +740,6 @@
|
||||
"showHiddenFilesDescription": "Muestra los archivos ocultos. (por ejemplo, .thumbnails, ProgramData), pero siguen siendo accesibles manualmente si están configurados para ser indexados.",
|
||||
"showQuickDownload": "Icono de descarga rápida",
|
||||
"showQuickDownloadDescription": "Mostrar icono de descarga rápida en cada archivo",
|
||||
"previewImagesDescription": "Mostrar miniaturas para imágenes, se mostrará un icono genérico si se desactiva",
|
||||
"previewVideosDescription": "Mostrar miniaturas para vídeos, se mostrará un icono genérico si se desactiva",
|
||||
"editOffice": "Ver y editar archivos de ofimática con OnlyOffice",
|
||||
"editOfficeDescription": "Cuando está activado, el visor/editor de archivos estará desactivado para todas las extensiones de archivo relacionadas con archivos office, en vez, las abrirá en OnlyOffice.",
|
||||
"filePreviewOptions": "Opciones de previsualización de archivos",
|
||||
@@ -774,7 +770,10 @@
|
||||
"popupPreviewDescription": "Se mostrará una ventana emergente superpuesta al pasar el mouse por encima de los elementos con miniaturas.",
|
||||
"thumbnailOptions": "Opciones de miniaturas",
|
||||
"showThumbnails": "Mostrar miniaturas soportadas",
|
||||
"showThumbnailsDescription": "Cuando está activada, se mostrarán miniaturas para todos los archivos soportados. Puedes desactivarlo para desactivar todas las miniaturas."
|
||||
"showThumbnailsDescription": "Cuando está activada, se mostrarán miniaturas para todos los archivos soportados. Puedes desactivarlo para desactivar todas las miniaturas.",
|
||||
"showCopyPath": "Mostrar ruta de copia en el menú contextual",
|
||||
"showCopyPathDescription": "Mostrar una acción Copiar ruta en el menú contextual de archivos y carpetas.",
|
||||
"previewDescription": "Mostrar vistas previas en miniatura para {filetype}, mostrará el icono genérico si está desactivado."
|
||||
},
|
||||
"editor": {
|
||||
"uninitialized": "No se pudo iniciar el editor",
|
||||
@@ -844,8 +843,6 @@
|
||||
"showingResults": "Se encontraron {count} iconos",
|
||||
"noResults": "No se pudieorn encontrar iconos",
|
||||
"tryDifferentSearch": "Prueba con otro nombre",
|
||||
"copiedToClipboard": "¡Se copió '{name}' al portapapeles!",
|
||||
"copyFailed": "Hubo un error al copiar al portapapeles",
|
||||
"custom": "Personalizar",
|
||||
"useCustomIcon": "Utilizar icono personalizado: {name}",
|
||||
"iconLibraryUrl": "fonts.google.com/icons",
|
||||
@@ -869,7 +866,8 @@
|
||||
"reassignPath": "Reasignar ruta",
|
||||
"pathReassigned": "¡La ruta se actualizó correctamente!",
|
||||
"pathReassignFailed": "No se pudo actualizar la ruta. Inténtalo de nuevo",
|
||||
"incompleteResultsDetails": "Mostrando resultados máximos ({max}). Intenta restringir la búsqueda para obtener más resultados en {max}."
|
||||
"incompleteResultsDetails": "Mostrando resultados máximos ({max}). Intenta restringir la búsqueda para obtener más resultados en {max}.",
|
||||
"noCreateOptions": "No hay opciones de creación disponibles"
|
||||
},
|
||||
"duplicateFinder": {
|
||||
"title": "Buscador de archivos duplicados",
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user