Compare commits

..
Author SHA1 Message Date
Graham Steffaniak 5597b28f10 update anonymous user access 2026-05-13 20:03:23 -05:00
Graham Steffaniak 6bfc397419 Fix inline svg attack (#2376) 2026-05-01 13:49:55 -05:00
Graham Steffaniak 112740bdd4 security bugfix (#2374) 2026-05-01 13:38:44 -05:00
Graham Steffaniak 9117fb2b5f fix context menu (#2373) 2026-05-01 10:05:04 -05:00
Graham Steffaniak 668efec4d7 added new screenshots (#2371) 2026-04-30 15:50:36 -05:00
Graham Steffaniak a5c81c8e2e Beta/v1.3.8 (#2363) 2026-04-30 11:55:14 -05:00
Graham Steffaniak 88aa337696 Beta/v1.3.7 (#2350) 2026-04-27 16:32:13 -05:00
9b48ce414e Beta/v1.3.6 (#2324)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-20 17:00:10 -05:00
0723edf660 Beta/v1.3.5 (#2316)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-18 18:24:10 -05:00
Gaurav Kalal d2165ec0c3 Fix: Prevent portrait mobile overflow for large placeholder loader (#2272) 2026-04-13 09:59:10 -05:00
78c554719f Beta/v1.3.4 (#2273)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: SIDDHU SINGH <siddhu3116@gmail.com>
Co-authored-by: Taufik Hidayat <taufikdev88@gmail.com>
2026-04-11 19:48:26 -05:00
Taufik Hidayat aa8f9e2923 Add resolve nested groupAdmin to oidc auth method (#2260) 2026-04-10 15:39:49 -05:00
Graham SteffaniakandKevin B. 3bfd9d9be0 Beta/v1.3.3 (#2234)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-04-03 13:40:25 -05:00
carloda ce5269e8e9 Fix and improve Italian translation (it.json) (#2198) 2026-03-30 15:57:45 -05:00
Kevin B. 6d21c7918e update spanish translation (#2200) 2026-03-30 15:57:25 -05:00
Graham Steffaniak 2693026f6c Revert "Update translation script" (#2182) 2026-03-23 19:22:18 -05:00
Kevin B. eb4dfa5710 Update translation script (#2178) 2026-03-23 19:21:25 -05:00
Stephan Paternotte 363619bcb4 Update nl.json - minor corrections (#2172) 2026-03-22 16:27:05 -05:00
ab9790427f Beta/v1.3.2 (#2169)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Gabriel Górski <me@glaeqen.eu>
Co-authored-by: Joel Godfrey <82655889+BaccanoMob@users.noreply.github.com>
Co-authored-by: Shane Chiu <71911511+pwn2ooown@users.noreply.github.com>
2026-03-21 16:22:31 -05:00
205 changed files with 11934 additions and 5758 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
{
"name": "filebrowser",
"image": "mcr.microsoft.com/devcontainers/go:1.25-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"containerEnv": {
"CGO_ENABLED": "1",
"FILEBROWSER_DEVMODE": "true"
},
"forwardPorts": [80],
"portsAttributes": {
"80": {
"label": "filebrowser app",
"onAutoForward": "notify"
}
},
"postCreateCommand": "make setup",
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
]
}
}
}
+10 -1
View File
@@ -56,4 +56,13 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run i18n:check
run: npm i && npm run i18n:check
check-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- run: make generate-docs
- run: git diff --exit-code
+2
View File
@@ -1,4 +1,6 @@
*.db
*.db.old
*.sqlite
*.bak
*.log
*.mjs
+107 -1
View File
@@ -2,6 +2,113 @@
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**:
- Copy/paste files and folders (`CTRL+C`/`CTRL+V`) from other apps (win explorer, thunar, finder, etc) to upload them directly (#2197)
- Caption font size can be adjusted in video settings
- Ability to adjust the startup check method for sql database via `server.startupIntegrityCheck` (#2221)
- Status bar for editor and markdown viewer (#2226)
**Notes**:
- `.fbx` 3d model files added to supported previews.
- removed upx compression on docker image (#2193)
- videos double tap to fast-forward and rewind added.
- Adjust Image album swipe behavior (#2068)
- swipe animation for next/previous
- swipe down on image to close and go to parent folder.
- supports videos
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
- The Icons in the UI were updated! (#2203)
- More supported icons in the Icon Picker tool.
- More file types have new icons across all the listings (such as `.md`, `.apk`, etc).
- `.fbx` 3d model files added to supported previews.
- removed upx compression on docker image (#2193)
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
- deprecated `source.config.CreateUserDir`, now its always `true`. If a user directory doesn't exist it will get created empty.
- `CTRL`+`Mouse Wheel` shortcut to change listing size. Also for change font size in editor (#2227)
**BugFixes**:
- Archives don't preserve file metadata (#2063)
- Chunked upload setting fails: "0" does not disable chunking as expected (#2202)
- Incorrect Chinese labels for Upload controls in Quantum UI (#2191)
- Chunked download stops after first chunk, add message explaining (#2074)
- Esc to Cancel and Enter to confirm popup (#2079)
- Language-tagged subtitle files not detected for videos (#2199)
- Unable to use proxy auth since 1.3.0-beta (#2173)
- Updating setting on a LDAP user turns the "Login Method" to password. Thus preventing further logins. (#2179)
## v1.3.2-beta
**Security**:
@@ -32,7 +139,6 @@ All notable changes to this project will be documented in this file. For commit
- LDAP authentication issue if a password caontains @ symbol (#2154)
- Share banner seems to be not working for custom urls (#2120)
## v1.3.1-beta
**Security**:
+1 -2
View File
@@ -4,13 +4,12 @@ ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev upx
RUN apk update && apk add --no-cache gcc musl-dev
ENV CGO_ENABLED=1
RUN go build -tags mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /app
+3 -2
View File
@@ -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
+1 -2
View File
@@ -3,12 +3,11 @@ ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
RUN apk update && apk add
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /app
+1
View File
@@ -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,
+107
View File
@@ -1,6 +1,7 @@
package files
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -11,6 +12,7 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
@@ -88,6 +90,111 @@ func Test_GetRealPath(t *testing.T) {
}
}
// Regression: PUT / save must not os.Chmod to PermFile for existing files, or +x (and other mode bits) are lost (#2309).
func TestWriteFilePreservesExecutableBit(t *testing.T) {
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
fileutils.SetFsPermissions(0o644, 0o755)
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_writefile_exec", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
root := t.TempDir()
scriptName := "backupscript.sh"
scriptAbs := filepath.Join(root, scriptName)
if err := os.WriteFile(scriptAbs, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
t.Fatal(err)
}
sourceName := "test_writefile_exec"
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: root,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatal("Failed to get test index")
}
stopPoll := startScannerStatusPoll(t, idx, 1)
defer stopPoll()
waitForScannerReady(t, idx)
indexPath := "/" + scriptName
if err := WriteFile(sourceName, indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
t.Fatalf("WriteFile: %v", err)
}
st, err := os.Stat(scriptAbs)
if err != nil {
t.Fatal(err)
}
if st.Mode()&0o111 == 0 {
t.Fatalf("executable bits cleared after save: mode=%v", st.Mode())
}
indexing.StopAllScanners()
time.Sleep(50 * time.Millisecond)
indexing.ClearTestIndices()
}
func TestWriteFileRejectsExistingDirectory(t *testing.T) {
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
fileutils.SetFsPermissions(0o644, 0o755)
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_writefile_isdir", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
root := t.TempDir()
dirName := "folder"
if err := os.Mkdir(filepath.Join(root, dirName), 0o755); err != nil {
t.Fatal(err)
}
sourceName := "test_writefile_isdir"
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: root,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatal("Failed to get test index")
}
stopPoll := startScannerStatusPoll(t, idx, 1)
defer stopPoll()
waitForScannerReady(t, idx)
err := WriteFile(sourceName, "/"+dirName, strings.NewReader("file content"))
if err == nil {
t.Fatal("expected error when path is an existing directory")
}
if !errors.Is(err, liberrors.ErrIsDirectory) {
t.Fatalf("expected ErrIsDirectory, got: %v", err)
}
indexing.StopAllScanners()
time.Sleep(50 * time.Millisecond)
indexing.ClearTestIndices()
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
+60 -79
View File
@@ -28,6 +28,36 @@ import (
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
var CheckPermissionsFunc = checkPermissionsImpl
// addMetadataToChildren runs concurrent ffmpeg/tag extraction for audio/video children.
// Each goroutine is registered on wg. Caller must invoke wg.Wait() after scheduling.
func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileOptions, wg *sync.WaitGroup) {
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService == nil {
return
}
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if !isItemAudio && !isItemVideo {
continue
}
isAudio := isItemAudio
wg.Go(func() {
fullPath := response.RealPath + "/" + fileItem.Name
if isAudio {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
}
}
})
}
}
// processDirectoryMetadata extracts metadata for audio/video files in directories
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
metadataCount := 0
@@ -39,11 +69,8 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
metadataCount++
}
}
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
// Refresh directory to update folder sizes
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true)
err := idx.RefreshDirectory(indexPath, false)
@@ -52,54 +79,9 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
}
})
// Set hasMetadata flag if there are files with potential metadata
if metadataCount > 0 {
response.HasMetadata = true
}
// Only process metadata if explicitly requested
if opts.Metadata && metadataCount > 0 {
processedCount := 0
// Create a single shared FFmpegService instance for all files to coordinate concurrency
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService != nil {
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if isItemAudio || isItemVideo {
wg.Go(func() {
// Extract metadata for audio files (without album art for performance)
if isItemAudio {
err := extractAudioMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, opts.AlbumArt, opts.Metadata, sharedFFmpegService)
if err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
} else {
mu.Lock()
processedCount++
mu.Unlock()
}
} else {
// Extract duration for video files
err := extractVideoMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, sharedFFmpegService)
if err != nil {
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
} else {
mu.Lock()
processedCount++
mu.Unlock()
}
}
})
}
}
}
addMetadataToChildren(response, opts, &wg)
}
// Wait for all goroutines to complete
wg.Wait()
}
@@ -244,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]
@@ -290,9 +271,13 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
if isFolder {
return
}
if isVideo && !opts.Metadata {
return
}
if isAudio && !(opts.Metadata || opts.AlbumArt) {
return
}
if isVideo {
// Extract duration for video
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
@@ -303,7 +288,6 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
info.Metadata = extItem.Metadata
}
// Handle subtitles if requested
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
if exists {
@@ -313,12 +297,12 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
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 {
// Create an ExtendedItemInfo to hold the metadata
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
@@ -326,9 +310,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
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.HasMetadata = true
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
}
return
@@ -779,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
@@ -807,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
+49 -9
View File
@@ -108,7 +108,6 @@ var types = map[string]string{
".chrt": "application/vnd.kde.kchart",
".class": "application/java",
".com": ContentTextHeaderValue,
".conf": ContentTextHeaderValue,
".cpio": "application/x-cpio",
".cpp": "text/x-c",
".cpt": "application/mac-compactpro",
@@ -151,7 +150,8 @@ var types = map[string]string{
".evy": "application/envoy",
".exe": "application/x-msdownload",
".dll": "application/x-msdownload",
".bat": "application/x-msdownload",
".bat": "application/x-msdos-program",
".cmd": "application/x-msdos-program",
".msi": "application/x-msdownload",
".f77": "text/x-fortran",
".f90": "text/x-fortran",
@@ -228,7 +228,6 @@ var types = map[string]string{
".js": ContentJavascriptHeaderValue,
".mjs": ContentJavascriptHeaderValue,
".json": ContentJSONHeaderValue,
".vue": ContentJavascriptHeaderValue,
".jut": "image/jutvision",
".kar": "audio/midi",
".karbon": "application/vnd.kde.karbon",
@@ -260,7 +259,6 @@ var types = map[string]string{
".m1v": "video/mpeg",
".m2a": "audio/mpeg",
".m2v": "video/mpeg",
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
".m": "text/x-m",
".man": "application/x-troff-man",
".manifest": "text/cache-manifest",
@@ -310,7 +308,6 @@ var types = map[string]string{
".ncm": "application/vndnokiaconfiguration-message",
".nif": "image/x-niff",
".niff": "image/x-niff",
".nix": "application/x-mix-transfer",
".nsc": "application/x-conference",
".nvd": "application/x-navidoc",
".o": "application/octet-stream",
@@ -346,15 +343,14 @@ var types = map[string]string{
".p7m": "application/pkcs7-mime",
".p7r": "application/x-pkcs7-certreqresp",
".p7s": "application/pkcs7-signature",
".p": "text/x-pascal",
".part": "application/pro_eng",
".pas": "text/pascal",
".pbm": "image/x-portable-bitmap",
".pcl": "application/vndhp-pcl",
".pct": "image/x-pict",
".pcx": "image/x-pcx",
".pdb": "chemical/x-pdb",
".pdf": "application/pdf",
".pdfa": "application/pdf",
".pfunk": "audio/make",
".pgm": "image/x-portable-graymap",
".pic": "image/pict",
@@ -446,9 +442,11 @@ var types = map[string]string{
".sprite": "application/x-sprite",
".spx": "audio/ogg",
".src": "application/x-wais-source",
".srt": "text/plain",
".srt": "text/subtitle-srt",
".ass": "text/subtitle-ass",
".vtt": "text/subtitle-vtt",
".ssa": "text/subtitle-ssa",
".sbv": "text/plain",
".ssa": "text/plain",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
".sst": "application/vndms-pkicertstore",
@@ -603,10 +601,52 @@ var types = map[string]string{
".webm": "video/webm",
".weba": "audio/webm",
".caf": "audio/x-caf",
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
".m3u8": "text/x-mpegURL",
".pls": "text/x-scpls",
".xspf": "application/xspf+xml",
".mpd": "application/dash+xml",
".webp": "image/webp",
".epub": "application/epub+zip",
".db": "application/x-db",
".db2": "application/x-db",
".db3": "application/x-db",
".sql": "application/sql",
".sqlite": "application/vnd.sqlite3",
".sqlite3": "application/vnd.sqlite3",
".yaml": "application/yaml",
".app": "application/x-application",
".geojson": "application/geo+json",
".sketch": "application/x-sketch",
".bak": "application/backup",
".backup": "application/backup",
".xfc": "application/x-xcf",
".vue": "text/vue",
".conf": "text/config-file",
".ini": "text/ini",
".ps1": "application/x-powershell",
".ps2": "application/x-powershell",
".ps3": "application/x-powershell",
".dart": "application/vnd.dart",
".flutter": "text/flutter",
".tsx": "text/x-typescript",
".ts": "text/x-typescript",
".php": "application/x-php",
".php2": "application/x-php",
".php3": "application/x-php",
".phtml": "application/x-php",
".p": "text/x-pascal",
".pas": "text/x-pascal",
".zig": "text/x-zig",
".ex": "text/x-elixir",
".exs": "text/x-elixir",
".json5": "application/json5",
".pmx": "model/x-pmx",
".nix": "text/nix-lang",
".cjs": "text/javascript",
".torrent": "application/x-bittorrent",
".tmp": "text/tmp",
".temp": "text/tmp",
// the ones below are some from: https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
".adp": "audio/adpcm",
".mp4a": "audio/mp4",
+13 -30
View File
@@ -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,48 +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 && source.Config.CreateUserDir && !disableScopeChange {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
// If parent directory doesn't exist and createUserDir is enabled, create it
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 && source.Config.CreateUserDir {
// 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
+39 -3
View File
@@ -12,9 +12,45 @@ import (
var PermFile os.FileMode
var PermDir os.FileMode
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
PermFile = PermFileOctal
PermDir = PermDirOctal
// SetFsPermissions sets create modes from Unix chmod(2)-style values (for example
// values produced by strconv.ParseUint(s, 8, 32)). setuid, setgid, and sticky
// bits must be converted for Go's os.FileMode; a plain cast from Unix octal is
// incorrect for those bits.
func SetFsPermissions(unixFileMode, unixDirMode uint32) {
PermFile = unixModeToFileMode(unixFileMode)
PermDir = unixModeToFileMode(unixDirMode)
}
// EffectiveDirPerm returns [PermDir] if [SetFsPermissions] was called, otherwise 0o755. On Unix,
// [os.Mkdir] with perm 0 fails with permission denied, so use this (or [PermFile]) for creates when
// the process has not set globals yet.
func EffectiveDirPerm() os.FileMode {
if PermDir == 0 {
return 0o755
}
return PermDir
}
// EffectiveFilePerm returns [PermFile] if set, otherwise 0o644.
func EffectiveFilePerm() os.FileMode {
if PermFile == 0 {
return 0o644
}
return PermFile
}
func unixModeToFileMode(u uint32) os.FileMode {
m := os.FileMode(u & 0777)
if u&0o4000 != 0 {
m |= os.ModeSetuid
}
if u&0o2000 != 0 {
m |= os.ModeSetgid
}
if u&0o1000 != 0 {
m |= os.ModeSticky
}
return m
}
// MoveFile moves a file from src to dst.
+17 -1
View File
@@ -1,6 +1,22 @@
package fileutils
import "testing"
import (
"os"
"testing"
)
func TestUnixModeToFileMode_setgid(t *testing.T) {
m := unixModeToFileMode(0o2770)
if got := m.Perm(); got != 0o770 {
t.Fatalf("Perm() = %#o, want 0770", got)
}
if m&os.ModeSetgid == 0 {
t.Fatal("expected ModeSetgid")
}
if m&os.ModeSetuid != 0 || m&os.ModeSticky != 0 {
t.Fatalf("unexpected special bits: %#o", m)
}
}
func TestCommonPrefix(t *testing.T) {
testCases := map[string]struct {
+4 -4
View File
@@ -15,7 +15,8 @@ import (
// JSONAuth is a json implementation of an Auther.
type JSONAuth struct {
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
DisableOtp bool `json:"disableOtp" yaml:"disableOtp"`
}
// Auth authenticates the user via a json in content body.
@@ -23,12 +24,12 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
password := r.Header.Get("X-Password")
totpCode := r.Header.Get("X-Secret")
// URL-decode password to support special characters in headers
password, err := url.QueryUnescape(password)
if err != nil {
return nil, fmt.Errorf("invalid password encoding: %v", err)
}
totpCode := r.Header.Get("X-Secret")
// If ReCaptcha is enabled, check the code.
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
@@ -58,8 +59,7 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
if getErr != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
// check for OTP for password
if user.TOTPSecret != "" {
if user.TOTPSecret != "" && !auther.DisableOtp {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
}
+102 -7
View File
@@ -1,7 +1,12 @@
package auth
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
@@ -47,14 +52,12 @@ func VerifyExternalJWT(tokenString string, secret string, algorithm string, user
if expectedMethod == nil {
return nil, fmt.Errorf("unsupported signing algorithm: %s", algorithm)
}
if token.Method.Alg() != expectedMethod.Alg() {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
// Return the secret key for HMAC algorithms
// For RSA/ECDSA, this would need to be a public key
return []byte(secret), nil
return jwtVerificationKey(secret, token.Method, algorithm)
})
if err != nil {
@@ -100,10 +103,102 @@ func VerifyExternalJWT(tokenString string, secret string, algorithm string, user
return username, claimsMap, nil
}
func jwtVerificationKey(secret string, method jwt.SigningMethod, algorithm string) (interface{}, error) {
switch method.(type) {
case *jwt.SigningMethodHMAC:
return []byte(secret), nil
case *jwt.SigningMethodRSA:
key, err := parsePublicKeyFromPEM(secret)
if err != nil {
return nil, err
}
rsaKey, ok := key.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("JWT secret must contain an RSA public key in PEM format for algorithm %s", algorithm)
}
return rsaKey, nil
case *jwt.SigningMethodECDSA:
key, err := parsePublicKeyFromPEM(secret)
if err != nil {
return nil, err
}
ecKey, ok := key.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("JWT secret must contain an ECDSA public key in PEM format for algorithm %s", algorithm)
}
if err := ecdsaCurveMatchesAlgorithm(algorithm, ecKey); err != nil {
return nil, err
}
return ecKey, nil
default:
return nil, fmt.Errorf("unsupported JWT signing method: %s", method.Alg())
}
}
func ecdsaCurveMatchesAlgorithm(algorithm string, pub *ecdsa.PublicKey) error {
var want elliptic.Curve
switch algorithm {
case "ES256":
want = elliptic.P256()
case "ES384":
want = elliptic.P384()
case "ES512":
want = elliptic.P521()
default:
return nil
}
if pub.Curve != want {
return fmt.Errorf("ECDSA public key curve does not match algorithm %s", algorithm)
}
return nil
}
func parsePublicKeyFromPEM(pemData string) (interface{}, error) {
rest := []byte(pemData)
var lastErr error
for len(rest) > 0 {
block, rem := pem.Decode(rest)
if block == nil {
break
}
rest = rem
var key interface{}
var err error
switch block.Type {
case "CERTIFICATE":
var cert *x509.Certificate
cert, err = x509.ParseCertificate(block.Bytes)
if err == nil {
key = cert.PublicKey
}
case "PUBLIC KEY":
key, err = x509.ParsePKIXPublicKey(block.Bytes)
case "RSA PUBLIC KEY":
key, err = x509.ParsePKCS1PublicKey(block.Bytes)
case "EC PUBLIC KEY":
key, err = x509.ParsePKIXPublicKey(block.Bytes)
default:
continue
}
if err != nil {
lastErr = err
continue
}
if key != nil {
return key, nil
}
}
if lastErr != nil {
return nil, fmt.Errorf("failed to parse JWT public key from PEM: %w", lastErr)
}
return nil, fmt.Errorf("no public key found in JWT secret (expected PEM: CERTIFICATE, PUBLIC KEY, RSA PUBLIC KEY, or EC PUBLIC KEY)")
}
// ExtractGroupsFromClaims extracts groups from JWT claims based on the configured groups claim field
func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField string) []string {
var groups []string
if groupsVal, ok := claims[groupsClaimField]; ok {
switch v := groupsVal.(type) {
case []interface{}:
@@ -129,6 +224,6 @@ func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField str
}
}
}
return groups
}
+142
View File
@@ -1,6 +1,14 @@
package auth_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
@@ -144,6 +152,140 @@ func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
}
}
func TestVerifyExternalJWT_ES256_PublicKeyPEM(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
username := "ec-user"
claims := jwt.MapClaims{
"sub": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tokenString, err := token.SignedString(priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "ES256", "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT: %v", err)
}
if got != username {
t.Errorf("username: got %q want %q", got, username)
}
}
func TestVerifyExternalJWT_RS256_PublicKeyPEM(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
username := "rsa-user"
claims := jwt.MapClaims{
"sub": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tokenString, err := token.SignedString(priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "RS256", "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT: %v", err)
}
if got != username {
t.Errorf("username: got %q want %q", got, username)
}
}
func TestVerifyExternalJWT_ES256_WrongCurveRejected(t *testing.T) {
p256Priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
p384Priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
verifyDER, err := x509.MarshalPKIXPublicKey(&p384Priv.PublicKey)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
verifyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: verifyDER}))
claims := jwt.MapClaims{
"sub": "u",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tokenString, err := token.SignedString(p256Priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
_, _, err = auth.VerifyExternalJWT(tokenString, verifyPEM, "ES256", "sub")
if err == nil {
t.Fatal("expected error when PEM public key curve does not match algorithm")
}
}
func TestVerifyExternalJWT_CertificatePEM(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
tpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "jwt-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
username := "cert-user"
claims := jwt.MapClaims{
"sub": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tokenString, err := token.SignedString(priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
got, _, err := auth.VerifyExternalJWT(tokenString, certPEM, "ES256", "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT: %v", err)
}
if got != username {
t.Errorf("username: got %q want %q", got, username)
}
}
func TestExtractGroupsFromClaims_ArrayOfStrings(t *testing.T) {
claims := map[string]interface{}{
"groups": []string{"admin", "users"},
+3 -4
View File
@@ -146,14 +146,14 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
}
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
// get data from cache
cachedSecret, found := TotpCache.Get(user.Username)
if !found && user.TOTPSecret == "" {
return fmt.Errorf("OTP token not found in cache, please generate a new one")
}
useCache := found && user.TOTPSecret == ""
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
if found {
if useCache {
splitSecret := strings.Split(cachedSecret, "||")
if len(splitSecret) < 2 {
return fmt.Errorf("invalid cached OTP token format")
@@ -173,9 +173,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
}
secretToValidate = decryptedSecret
}
// --- END: ADD THIS DECRYPTION LOGIC ---
// Validate the token using the (now plaintext) secret.
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
Period: TOTPPeriod,
Skew: TOTPSkew,
@@ -198,6 +196,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
logger.Error("error updating user with OTP token:", err)
return fmt.Errorf("error updating user with OTP token: %w", err)
}
TotpCache.Delete(user.Username)
} else {
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
}
+70 -60
View File
@@ -23,7 +23,7 @@ var (
)
// return bool to indicate if the program should continue running
func runCLI() bool {
func runCLI() (bool, bool) {
generateYaml()
@@ -53,85 +53,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
View File
@@ -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
View File
@@ -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,
-1
View File
@@ -156,7 +156,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
sourceA = settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
DefaultEnabled: true,
DefaultUserScope: "/defaultA",
CreateUserDir: true,
}}
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
+1 -1
View File
@@ -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)
}
}
+2 -2
View File
@@ -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"
}
+25 -17
View File
@@ -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()
@@ -682,10 +682,11 @@ func setDefaults(generate bool) Settings {
CacheDir: "tmp",
MaxArchiveSizeGB: 20,
IndexSqlConfig: IndexSqlConfig{
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
StartupIntegrityCheck: IndexStartupIntegrityQuickCheck,
},
Filesystem: Filesystem{
CreateFilePermission: "644",
@@ -707,19 +708,19 @@ func setDefaults(generate bool) Settings {
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
DeleteAfterArchive: true,
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
HideFilesInTree: false,
DeleteAfterArchive: true,
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
HideFilesInTree: false,
PreferEditorForMarkdown: false,
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: UserDefaultsPermissions{
Modify: false,
Share: false,
@@ -816,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() {
+60 -42
View File
@@ -65,54 +65,72 @@ 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.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)
// 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)
if u.Username == "anonymous" {
return
}
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" {
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)
if u.LoginMethod == "" && d.LoginMethod != "" {
u.LoginMethod = users.LoginMethod(d.LoginMethod)
}
if len(u.Scopes) == 0 {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
u.Scopes = append(u.Scopes, users.SourceScope{
+15 -8
View File
@@ -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 {
@@ -76,11 +72,22 @@ type Filesystem struct {
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
}
// Index SQL startup integrity modes (IndexSqlConfig.StartupIntegrityCheck).
const (
// IndexStartupIntegrityQuickCheck runs PRAGMA quick_check (default). Slower on very large DBs but thorough.
IndexStartupIntegrityQuickCheck = "quickCheck"
// IndexStartupIntegrityProbe verifies sqlite_master and optionally one row read only; fast for large indexes.
IndexStartupIntegrityProbe = "probe"
// IndexStartupIntegrityOff skips startup checks beyond sql.Open Ping; least safe, fastest boot.
IndexStartupIntegrityOff = "off"
)
type IndexSqlConfig struct {
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
StartupIntegrityCheck string `json:"startupIntegrityCheck,omitempty" validate:"omitempty,oneof=quickCheck probe off"` // the method used to check the integrity of the index database on startup (default: quickCheck)
}
type Integrations struct {
+5
View File
@@ -17,6 +17,11 @@ type IndexInfo struct {
NumDirs uint64 `json:"numDirs"`
NumFiles uint64 `json:"numFiles"`
// Disk usage (cached from periodic GetIndexInfo / partition probes; aligns with ReducedIndex Stats)
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
// Scanner information - map of scanner path to scanner stats
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
}
+4
View File
@@ -211,5 +211,9 @@ func (l *Link) GetShareImagePartsHelper(isBanner bool) (string, string, error) {
sourceName := assetParams.Get("source")
assetPath := assetParams.Get("path")
if sourceName == "" || assetPath == "" {
return "", "", fmt.Errorf("source or path not found in asset configuration")
}
return sourceName, assetPath, nil
}
+153 -100
View File
@@ -2,6 +2,7 @@ package sql
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
@@ -90,7 +91,8 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
// For persistent databases, check integrity on startup
// If corrupted, delete and recreate the database
if !disableReuse {
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
mode := normalizeStartupIntegrityMode(settings.Config.Server.IndexSqlConfig.StartupIntegrityCheck)
if err := idxDB.runStartupIntegrityCheck(mode); err != nil {
// Database was recreated due to corruption
logger.Warningf("[DB_INIT] SQLite database was found with issues which could be due to a dirty shutdown, recreating: tmp/index_%s.db", name)
isNewDb = true
@@ -112,10 +114,73 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
return idxDB, isNewDb, nil
}
func normalizeStartupIntegrityMode(m string) string {
switch m {
case "", settings.IndexStartupIntegrityQuickCheck:
return settings.IndexStartupIntegrityQuickCheck
case settings.IndexStartupIntegrityProbe:
return settings.IndexStartupIntegrityProbe
case settings.IndexStartupIntegrityOff:
return settings.IndexStartupIntegrityOff
default:
logger.Warningf("[DB_INIT] unknown indexSqlConfig.startupIntegrityCheck %q, using %s", m, settings.IndexStartupIntegrityQuickCheck)
return settings.IndexStartupIntegrityQuickCheck
}
}
// runStartupIntegrityCheck runs the configured startup check (quick_check, probe, or off).
func (db *IndexDB) runStartupIntegrityCheck(mode string) error {
switch mode {
case settings.IndexStartupIntegrityOff:
logger.Debugf("[DB_INTEGRITY] startup integrity skipped (startupIntegrityCheck=off)")
return nil
case settings.IndexStartupIntegrityProbe:
return db.checkAccessibilityProbe()
default:
// quickCheck (including normalized default)
return db.checkIntegrityAndRecreateIfNeeded()
}
}
// checkAccessibilityProbe verifies the DB file is readable: sqlite_master and optionally one row from index_items.
// If index_items is absent (brand-new file), succeeds so CreateIndexTable can create it.
func (db *IndexDB) checkAccessibilityProbe() error {
start := time.Now()
defer func() {
logger.Debugf("[DB_INTEGRITY] startup probe completed in %v", time.Since(start))
}()
var n int
err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'index_items'`).Scan(&n)
if err != nil {
logger.Warningf("[DB_INTEGRITY] probe: cannot read sqlite_master: %v", err)
return db.deleteCorruptedDatabase()
}
if n == 0 {
logger.Debugf("[DB_INTEGRITY] probe: index_items not present yet (new database), skipping row read")
return nil
}
var dummy int
err = db.QueryRow(`SELECT 1 FROM index_items LIMIT 1`).Scan(&dummy)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
logger.Warningf("[DB_INTEGRITY] probe: cannot read index_items: %v", err)
return db.deleteCorruptedDatabase()
}
return nil
}
// checkIntegrityAndRecreateIfNeeded checks database integrity.
// If corruption is detected, deletes the database file and returns an error to trigger recreation.
// Returns nil if integrity is OK, error if database needs to be recreated.
func (db *IndexDB) checkIntegrityAndRecreateIfNeeded() error {
start := time.Now()
defer func() {
logger.Debugf("[DB_INTEGRITY] PRAGMA quick_check completed in %v", time.Since(start))
}()
// Quick integrity check
var result string
err := db.QueryRow("PRAGMA quick_check").Scan(&result)
@@ -233,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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -271,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 {
@@ -298,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)
@@ -409,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 := `
@@ -1012,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
@@ -1198,23 +1267,6 @@ func (db *IndexDB) GetSizeGroupsForDuplicates(source string, minSize int64, path
return sizes, sizeCounts, rows.Err()
}
// GetTotalSize returns the sum of all file sizes in the index for a specific source (excluding directories).
func (db *IndexDB) GetTotalSize(source string) (uint64, error) {
query := `SELECT COALESCE(SUM(size), 0) FROM index_items WHERE source = ? AND is_dir = 0`
var totalSize int64
err := db.QueryRow(query, source).Scan(&totalSize)
if err != nil {
// Soft failure: DB is busy or locked, return 0
if isBusyError(err) || isTransactionError(err) {
return 0, nil
}
return 0, err
}
return uint64(totalSize), nil
}
// Helper functions
// getNextPathPrefix returns the lexicographically next string after the given prefix.
@@ -1272,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
}
+129
View File
@@ -0,0 +1,129 @@
package sql
import (
"os"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func TestMain(m *testing.M) {
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0o644, 0o755)
}
os.Exit(m.Run())
}
func TestNormalizeStartupIntegrityMode(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", settings.IndexStartupIntegrityQuickCheck},
{"quickCheck", settings.IndexStartupIntegrityQuickCheck},
{"probe", settings.IndexStartupIntegrityProbe},
{"off", settings.IndexStartupIntegrityOff},
{"not-a-real-mode", settings.IndexStartupIntegrityQuickCheck},
}
for _, tt := range tests {
if got := normalizeStartupIntegrityMode(tt.in); got != tt.want {
t.Errorf("normalizeStartupIntegrityMode(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
// pushTestIndexConfig sets CacheDir and index SQL options for NewIndexDB; returned func restores prior values.
func pushTestIndexConfig(t *testing.T, cacheDir string, idx settings.IndexSqlConfig) func() {
t.Helper()
prevDir := settings.Config.Server.CacheDir
prevIdx := settings.Config.Server.IndexSqlConfig
settings.Config.Server.CacheDir = cacheDir
settings.Config.Server.IndexSqlConfig = idx
return func() {
settings.Config.Server.CacheDir = prevDir
settings.Config.Server.IndexSqlConfig = prevIdx
}
}
func testIndexSQLConfig(mode string) settings.IndexSqlConfig {
return settings.IndexSqlConfig{
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
StartupIntegrityCheck: mode,
}
}
func TestNewIndexDB_StartupProbe_NewFile(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
defer pop()
db, _, err := NewIndexDB("probe_new", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
func TestNewIndexDB_StartupProbe_ReopenWithData(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
defer pop()
db, _, err := NewIndexDB("probe_reopen", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
info := &iteminfo.FileInfo{
Path: "/a.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "a.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
Hidden: false,
},
}
if err = db.InsertItem("src1", "/a.txt", info); err != nil {
t.Fatal(err)
}
if err = db.Close(); err != nil {
t.Fatal(err)
}
var db2 *IndexDB
db2, _, err = NewIndexDB("probe_reopen", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db2.Close()
}
func TestNewIndexDB_StartupQuickCheckStillWorks(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityQuickCheck))
defer pop()
db, _, err := NewIndexDB("quick_default", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
func TestNewIndexDB_StartupOff(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
defer pop()
db, _, err := NewIndexDB("off_mode", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
+21 -10
View File
@@ -74,11 +74,6 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return err
}
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
return errors.ErrNoTotpConfigured
}
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
@@ -86,11 +81,21 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
if err != nil {
return err
}
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
if user.TOTPNonce == "" {
user.TOTPNonce = existingUser.TOTPNonce
}
if user.TOTPSecret == "" {
user.TOTPSecret = existingUser.TOTPSecret
}
if user.LoginMethod == users.LoginMethodPassword && enforcedOtp && !user.OtpEnabled {
if slices.Contains(fields, "OtpEnabled") {
return errors.ErrNoTotpConfigured
}
}
if !slices.Contains(fields, "Password") {
user.Password = existingUser.Password
} else {
@@ -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())
}
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -147,6 +147,22 @@ type AuthTokenFrontend struct {
Permissions users.Permissions `json:"Permissions,omitempty"`
}
// authTokenIssuedUnix returns iat from JWT claims when set, otherwise the persisted int64 (e.g. loaded from DB).
func authTokenIssuedUnix(t users.AuthToken) int64 {
if t.RegisteredClaims.IssuedAt != nil {
return t.RegisteredClaims.IssuedAt.Unix()
}
return t.IssuedAt
}
// authTokenExpiresUnix returns exp from JWT claims when set, otherwise the persisted int64.
func authTokenExpiresUnix(t users.AuthToken) int64 {
if t.RegisteredClaims.ExpiresAt != nil {
return t.RegisteredClaims.ExpiresAt.Unix()
}
return t.ExpiresAt
}
// listApiTokensHandler lists all API tokens or retrieves details for a specific token.
// @Summary List API tokens
// @Description List all API tokens or retrieve details for a specific token.
@@ -169,8 +185,8 @@ func listApiTokensHandler(w http.ResponseWriter, r *http.Request, d *requestCont
AuthTokensFrontend = append(AuthTokensFrontend, AuthTokenFrontend{
Token: token.Token,
Name: name,
IssuedAt: token.RegisteredClaims.IssuedAt.Unix(),
ExpiresAt: token.RegisteredClaims.ExpiresAt.Unix(),
IssuedAt: authTokenIssuedUnix(token),
ExpiresAt: authTokenExpiresUnix(token),
Permissions: token.Permissions,
})
}
@@ -205,8 +221,8 @@ func getApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContex
AuthTokenFrontendResponse := AuthTokenFrontend{
Token: tokenInfo.Token,
Name: name,
IssuedAt: tokenInfo.IssuedAt,
ExpiresAt: tokenInfo.ExpiresAt,
IssuedAt: authTokenIssuedUnix(tokenInfo),
ExpiresAt: authTokenExpiresUnix(tokenInfo),
Permissions: tokenInfo.Permissions,
}
return renderJSON(w, r, AuthTokenFrontendResponse)
+287 -71
View File
@@ -8,10 +8,14 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -101,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"
@@ -170,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
}
@@ -297,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)
@@ -416,16 +417,21 @@ func addFile(source string, path string, d *requestContext, tarWriter *tar.Write
if fileInfo.IsDir() {
if tarWriter != nil {
header := &tar.Header{
Name: relPath + "/",
Mode: int64(fileutils.PermDir),
Typeflag: tar.TypeDir,
ModTime: fileInfo.ModTime(),
header, err := tar.FileInfoHeader(fileInfo, "")
if err != nil {
return err
}
header.Name = relPath + "/"
return tarWriter.WriteHeader(header)
}
if zipWriter != nil {
_, err := zipWriter.Create(relPath + "/")
zh, err := zip.FileInfoHeader(fileInfo)
if err != nil {
return err
}
zh.Name = relPath + "/"
zh.Method = zip.Store
_, err = zipWriter.CreateHeader(zh)
return err
}
return nil
@@ -642,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
@@ -657,12 +703,130 @@ 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
}
// applyArchivedTimesAndPerm sets permission bits and modification time from an archive entry using
func applyArchivedTimesAndPerm(path string, mode fs.FileMode, modTime time.Time) error {
switch {
case mode.Type() == fs.ModeSymlink:
if !modTime.IsZero() {
_ = os.Chtimes(path, modTime, modTime)
}
return nil
case mode.Type() == fs.ModeDir:
if p := mode.Perm(); p != 0 {
_ = os.Chmod(path, p)
}
if modTime.IsZero() {
return nil
}
return os.Chtimes(path, modTime, modTime)
case mode.IsRegular():
if p := mode.Perm(); p != 0 {
_ = os.Chmod(path, p)
}
if modTime.IsZero() {
return nil
}
return os.Chtimes(path, modTime, modTime)
default:
if !modTime.IsZero() {
_ = os.Chtimes(path, modTime, modTime)
}
return nil
}
}
// symlinkTargetStaysUnderDest rejects absolute targets and paths that would resolve outside destRoot.
func symlinkTargetStaysUnderDest(destRoot, destPath, linkname string) error {
if linkname == "" {
return fmt.Errorf("empty symlink target")
}
if filepath.IsAbs(linkname) {
return fmt.Errorf("symlink has absolute target")
}
destAbs, err := filepath.Abs(destRoot)
if err != nil {
return err
}
resolved := filepath.Clean(filepath.Join(filepath.Dir(destPath), linkname))
resAbs, err := filepath.Abs(resolved)
if err != nil {
return err
}
sep := string(filepath.Separator)
if resAbs != destAbs && !strings.HasPrefix(resAbs, destAbs+sep) {
return fmt.Errorf("symlink target escapes destination directory")
}
return nil
}
// extractArchivedDir creates a directory from an archive entry and reapplies mode and mtime.
func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) error {
perm := mode.Perm()
if perm == 0 {
perm = fileutils.EffectiveDirPerm()
}
if err := os.MkdirAll(destPath, perm); err != nil {
return fmt.Errorf("mkdir %q: %w", destPath, err)
}
return applyArchivedTimesAndPerm(destPath, mode, modTime)
}
// 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() }()
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.EffectiveFilePerm()
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
_, copyErr := io.Copy(out, src)
cerr := out.Close()
if copyErr != nil {
return copyErr
}
if cerr != nil {
return cerr
}
return applyArchivedTimesAndPerm(destPath, mode, modTime)
}
// 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)
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
}
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.Symlink(target, destPath); err != nil {
return err
}
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
}
func extractZip(archivePath, destDir string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
@@ -670,34 +834,82 @@ 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 {
destPath, err := safeExtractPath(destDir, f.Name)
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 {
return err
}
if f.FileInfo().IsDir() {
if err = os.MkdirAll(destPath, fileutils.PermDir); err != nil {
fi := f.FileInfo()
mode := fi.Mode()
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
}
continue
}
if err = os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
if mode.Type() == fs.ModeSymlink {
var rc io.ReadCloser
rc, err = f.Open()
if err != nil {
return err
}
buf, rerr := io.ReadAll(rc)
rc.Close()
if rerr != nil {
return rerr
}
if err = extractArchivedSymlink(destDir, destPath, string(buf), f.Modified); err != nil {
return err
}
continue
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if !mode.IsRegular() {
continue
}
var rc io.ReadCloser
rc, err = f.Open()
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
out.Close()
return err
}
_, err = io.Copy(out, rc)
rc.Close()
out.Close()
if err != nil {
if err = extractArchivedRegularFile(destPath, mode, f.Modified, rc); err != nil {
return err
}
}
@@ -726,29 +938,33 @@ func extractTarGz(archivePath, destDir string) error {
if err != nil {
return err
}
destPath, err := safeExtractPath(destDir, h.Name)
var destPath string
destPath, err = safeExtractPath(destDir, h.Name)
if err != nil {
return err
}
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(destPath, fileutils.PermDir); err != nil {
mode := h.FileInfo().Mode()
if err = extractArchivedDir(destPath, mode, h.ModTime); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
mode := h.FileInfo().Mode()
if err = extractArchivedRegularFile(destPath, mode, h.ModTime, io.NopCloser(tr)); err != nil {
return err
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
case tar.TypeSymlink:
if err = extractArchivedSymlink(destDir, destPath, h.Linkname, h.ModTime); err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
default:
if h.Size > 0 {
if _, err = io.CopyN(io.Discard, tr, h.Size); err != nil {
return err
}
}
out.Close()
}
}
return nil
+289
View File
@@ -0,0 +1,289 @@
package http
import (
"archive/zip"
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalizeArchiveEntryName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
wantErr string
}{
{
name: "simple forward slashes",
input: "DOCS/guide.pdf",
want: "DOCS/guide.pdf",
},
{
name: "Windows backslashes become logical path",
input: `DOCS\QUICK\chs.gif`,
want: "DOCS/QUICK/chs.gif",
},
{
name: "Intel-style leading backslash stripped so path stays relative",
input: `\PRO1000\Winx64\NDIS65\e1c65x64.cat`,
want: "PRO1000/Winx64/NDIS65/e1c65x64.cat",
},
{
name: "leading forward slashes stripped",
input: "/PRO1000/Winx64/readme.txt",
want: "PRO1000/Winx64/readme.txt",
},
{
name: "directory entry with trailing slash",
input: `PRO1000\Winx64\`,
want: "PRO1000/Winx64",
},
{
name: "mixed redundant segments cleaned",
input: "a/./b/../c/file",
want: "a/c/file",
},
{
name: "empty",
input: "",
wantErr: "empty path",
},
{
name: "whitespace only",
input: " ",
wantErr: "empty path",
},
{
name: "UNC rejected",
input: `//server/share/file`,
wantErr: "UNC or invalid path",
},
{
name: "double slash prefix after backslash replace (UNC-style)",
input: `\\?\C:\temp\file`,
wantErr: "UNC or invalid path",
},
{
name: "Windows drive absolute rejected",
input: `C:/Windows/system32`,
wantErr: "absolute path in archive",
},
{
name: "lowercase drive rejected",
input: `c:/data/file.txt`,
wantErr: "absolute path in archive",
},
{
name: "bare dot-dot",
input: "..",
wantErr: "path traversal",
},
{
name: "prefixed dot-dot",
input: "../etc/passwd",
wantErr: "path traversal",
},
{
name: "traversal after path clean",
input: "safe/../../outside",
wantErr: "path traversal after clean",
},
{
name: "only dots and slashes",
input: "./",
wantErr: "empty path after clean",
},
{
name: "path cleans to nothing",
input: "x/../.",
wantErr: "empty path after clean",
},
{
name: "spaces preserved in file name",
input: "My Folder/read me.txt",
want: "My Folder/read me.txt",
},
{
name: "deep relative tree",
input: "a/b/c/d/e/f/g.bin",
want: "a/b/c/d/e/f/g.bin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeArchiveEntryName(tt.input)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil (result %q)", tt.wantErr, got)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("normalizeArchiveEntryName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
// TestNormalizeArchiveEntryName_backslashInFileNameOnUnix documents that after normalization
// a single backslash in the raw name is never a path separator: it becomes a slash in the
// logical path (portable), so segments split correctly on every GOOS in safeExtractPath.
func TestNormalizeArchiveEntryName_unifiesBackslashes(t *testing.T) {
t.Parallel()
got, err := normalizeArchiveEntryName(`folder\file.txt`)
if err != nil {
t.Fatal(err)
}
if want := "folder/file.txt"; got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestSafeExtractPath(t *testing.T) {
t.Parallel()
dest := t.TempDir()
dest, err := filepath.Abs(dest)
if err != nil {
t.Fatal(err)
}
t.Run("file under dest", func(t *testing.T) {
t.Parallel()
got, err := safeExtractPath(dest, `DOCS\Adapter_User_Guide.pdf`)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(dest, "DOCS", "Adapter_User_Guide.pdf")
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
})
t.Run("leading backslash in zip name stays under dest", func(t *testing.T) {
t.Parallel()
got, err := safeExtractPath(dest, `\sub\file.txt`)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got, dest+string(filepath.Separator)) {
t.Fatalf("result %q should be under dest %q", got, dest)
}
rel, err := filepath.Rel(dest, got)
if err != nil {
t.Fatal(err)
}
if rel != filepath.Join("sub", "file.txt") {
t.Fatalf("Rel = %q, want sub%cfile.txt", rel, filepath.Separator)
}
})
t.Run("rejects path that escapes dest", func(t *testing.T) {
t.Parallel()
_, err := safeExtractPath(dest, "../../../x")
if err == nil {
t.Fatal("expected error for parent segments in archive name")
}
if !strings.Contains(err.Error(), "invalid entry path") {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("rejects when normalize fails", func(t *testing.T) {
t.Parallel()
_, err := safeExtractPath(dest, "..")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "invalid entry path") {
t.Fatalf("got %v", err)
}
})
}
// TestSafeExtractPath_rejectsParentTraversal ensures ".." in the archive name is rejected
// by normalizeArchiveEntryName, not written under dest.
func TestSafeExtractPath_rejectsParentTraversal(t *testing.T) {
t.Parallel()
dest := t.TempDir()
_, err := safeExtractPath(dest, `pkg/../../../secret`)
if err == nil {
t.Fatal("expected error for path traversal in archive name")
}
}
// TestSafeExtractPath_destJoinDoesNotDropBase verifies that a previously problematic pattern
// (root-relative second segment) does not throw away dest after normalization.
func TestSafeExtractPath_destJoinDoesNotDropBase(t *testing.T) {
t.Parallel()
dest := t.TempDir()
out, err := safeExtractPath(dest, `a\b\c.txt`)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(out, dest+string(filepath.Separator)) {
t.Fatalf("expected path under %q, got %q", dest, out)
}
}
// TestExtractZip_nestedBackslashes extracts a Windows-style path in a zip (backslashes) into
// nested directories under dest. This matches many vendor driver zips.
func TestExtractZip_nestedBackslashes(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "test.zip")
out := filepath.Join(tmp, "out")
if err := os.MkdirAll(out, 0o755); err != nil {
t.Fatal(err)
}
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
w, err := zw.Create(`DOCS\QUICK\readme.txt`)
if err != nil {
t.Fatal(err)
}
if _, err = w.Write([]byte("hello")); err != nil {
t.Fatal(err)
}
if err = zw.Close(); err != nil {
t.Fatal(err)
}
if err = f.Close(); err != nil {
t.Fatal(err)
}
if err = extractZip(zipPath, out); err != nil {
t.Fatalf("extractZip: %v", err)
}
b, err := os.ReadFile(filepath.Join(out, "DOCS", "QUICK", "readme.txt"))
if err != nil {
t.Fatal(err)
}
if string(b) != "hello" {
t.Fatalf("content = %q", b)
}
}
// Benchmarks (optional, keep small)
func BenchmarkNormalizeArchiveEntryName(b *testing.B) {
const name = `PRO1000\Winx64\NDIS65\e1c65x64.cat`
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = normalizeArchiveEntryName(name)
}
}
+2
View File
@@ -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
+1
View File
@@ -219,6 +219,7 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
}
if isText {
response.IsText = true
// Read the last N lines for text files only
content, err := readLastNLines(realPath, lines)
if err != nil {
+9 -5
View File
@@ -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))
@@ -119,6 +119,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("POST /resources/unarchive", withUser(unarchiveHandler))
api.HandleFunc("GET /resources/download", withUser(downloadHandler))
api.HandleFunc("GET /resources/preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
api.HandleFunc("POST /resources/pause", withUser(resourcePauseHandler))
publicApi.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
publicApi.HandleFunc("GET /resources/items", withHashFile(publicItemsGetHandler))
publicApi.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
@@ -128,6 +129,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicApi.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
publicApi.HandleFunc("GET /resources/download", withHashFile(publicDownloadHandler))
publicApi.HandleFunc("GET /resources/preview", withHashFile(publicPreviewHandler))
publicApi.HandleFunc("POST /resources/pause", withHashFile(publicPauseHandler))
// Legacy routes (backwards compatibility)
api.HandleFunc("GET /raw", withUser(downloadHandler))
publicApi.HandleFunc("GET /raw", withHashFile(publicDownloadHandler))
@@ -148,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))
@@ -171,9 +173,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("GET /tools/fileWatcher/sse", withUser(fileWatchSSEHandler))
// ========================================
// Media Routes - /api/media/
// Media Routes - /api/media/ (with public routes)
// ========================================
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
api.HandleFunc("GET /media/metadata", withUser(metadataHandler))
publicApi.HandleFunc("GET /media/metadata", withHashFile(publicMetadataHandler))
// ========================================
// OnlyOffice Routes - /api/office/ (with public routes)
+4 -10
View File
@@ -80,17 +80,11 @@ func TestPickUserEntry(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pickUserEntry(tt.entries)
if tt.wantNil {
if got != nil {
t.Errorf("pickUserEntry() = %v, want nil", got)
}
return
if tt.wantNil && got != nil {
t.Errorf("pickUserEntry() = %v, want nil", got)
}
if got == nil {
t.Fatal("pickUserEntry() = nil, want non-nil entry")
}
if got.DN != tt.wantDN {
t.Errorf("pickUserEntry() DN = %v, want %v", got.DN, tt.wantDN)
if !tt.wantNil && got == nil {
t.Errorf("pickUserEntry() = nil, want non-nil entry")
}
})
}
+76
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
@@ -99,3 +101,77 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
return http.StatusOK, nil
}
// metadataHandler returns the same directory resource shape as GET /api/resources with metadata enabled,
// for client-side patching after a fast listing load.
// @Summary Directory with media metadata
// @Description Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).
// @Tags Resources
// @Accept json
// @Produce json
// @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"
// @Router /api/media/metadata [get]
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,
Source: source,
Expand: true,
Content: false,
Metadata: true,
AlbumArt: albumArt,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
SkipExtendedAttrs: false,
ShowSharedAttr: true,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
}
return renderJSON(w, r, fileInfo)
}
// publicMetadataHandler is the share-link variant of metadataHandler.
// @Summary Directory with media metadata (public share)
// @Tags Shares
// @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) {
if d.share.ShareType == "upload" {
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")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: d.IndexPath,
Source: sourceCfg.Name,
Expand: true,
Content: false,
Metadata: true,
AlbumArt: albumArt,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
ShowHidden: d.share.ShowHidden,
FollowSymlinks: false,
}, store.Access, d.shareUser, store.Share)
if err != nil {
return errToStatus(err), err
}
fileInfo.Path = utils.AddTrailingSlashIfNotExists(path)
return renderJSON(w, r, fileInfo)
}
+23 -32
View File
@@ -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)
@@ -116,7 +120,9 @@ func withHashFileHelper(fn handleFunc) handleFunc {
}
data.IndexPath = pathWithoutUserScope
// skip file fetch for certain apis
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") || r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items") {
if (r.Method == "POST" && strings.Contains(r.URL.Path, "/resources")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) {
return fn(w, r, data)
}
file, err := FileInfoFasterFunc(utils.FileOptions{
@@ -312,32 +318,13 @@ 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) {
// Check if request has a valid admin token first
if tokenStr, err := extractToken(r); err == nil && tokenStr != "" {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(config.Auth.Key), nil
}
var tk users.AuthToken
if token, err := jwt.ParseWithClaims(tokenStr, &tk, keyFunc); err == nil && token.Valid {
if !auth.IsRevokedApiToken(store.Access, tokenStr) {
if tk.BelongsTo == 0 {
// Minimal token - look up user ID
userID, found := store.Access.GetUserIDFromToken(tokenStr)
if found {
tk.BelongsTo = userID
}
}
if tk.BelongsTo > 0 {
user, err := store.Users.Get(tk.BelongsTo)
if err == nil && user.Permissions.Admin {
// Valid admin token - allow operation
d.user = user
return fn(w, r, d)
}
}
}
if config.Auth.Methods.ProxyAuth.Enabled {
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if proxyUser != "" {
return getProxyUser(w, r, d, fn, proxyUser)
}
}
@@ -361,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
}
@@ -672,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
View File
@@ -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 {
+58
View File
@@ -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 {
+1 -1
View File
@@ -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)
+139 -22
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -22,9 +23,24 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var pauseCache = cache.NewCache[string](1 * time.Minute)
var publicPauseCache = cache.NewCache[string](1 * time.Minute)
// pauseCacheKeySep separates segments in pause cache keys (not used in source names/paths).
const pauseCacheKeySep = "\x1e"
func pauseUploadCacheKey(source, path string) string {
return source + pauseCacheKeySep + path
}
func publicPauseUploadCacheKey(shareHash, source, indexPath string) string {
return shareHash + pauseCacheKeySep + source + pauseCacheKeySep + indexPath
}
// validateMoveOperation checks if a move/rename operation is valid at the HTTP level
// It prevents moving a directory into itself or its subdirectories
func validateMoveOperation(src, dst string, isSrcDir bool) error {
@@ -61,10 +77,10 @@ func validateMoveOperation(src, dst string, isSrcDir bool) error {
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param skipExtendedAttrs query string false "Skip extended attributes for faster retrieval, no hasPreview"
// @Param skipExtendedAttrs query string false "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction"
// @Param source query string true "Source name for the desired source, default is used if not provided"
// @Param content query string false "Include file content if true"
// @Param metadata query string false "Extract audio/video metadata if true"
// @Param metadata query string false "When true, run audio/video metadata extraction, subtitles, and directory media batch processing"
// @Param checksum query string false "Optional checksum validation"
// @Success 200 {object} iteminfo.FileInfo "Resource metadata"
// @Failure 404 {object} map[string]string "Resource not found"
@@ -235,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()
@@ -265,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,
@@ -289,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
@@ -301,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
@@ -312,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
@@ -322,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
@@ -331,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
@@ -347,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
@@ -367,6 +393,74 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
return renderJSON(w, r, response, statusCode)
}
// resourcePauseHandler registers a graceful pause for an in-flight chunked upload.
// Call immediately before aborting the chunk POST so the server keeps partial data.
// @Summary Register graceful chunked upload pause
// @Description Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Destination path (same as upload)"
// @Param source query string true "Source name (same as upload)"
// @Success 200 "Pause registered"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Source not found"
// @Router /api/resources/pause [post]
func resourcePauseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share != nil {
return http.StatusBadRequest, fmt.Errorf("use public pause endpoint for share uploads")
}
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to pause uploads")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, err
}
path = cleanPath
idx := indexing.GetIndex(source)
if idx == nil {
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
if _, err := d.user.GetScopeForSourceName(source); err != nil {
return http.StatusForbidden, err
}
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
pauseCache.Set(pauseUploadCacheKey(source, path), "1")
return http.StatusOK, nil
}
// publicPauseHandler registers a graceful pause for a public share chunked upload.
// @Summary Register graceful chunked upload pause (public share)
// @Description Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash"
// @Param path query string true "Path within the share (same as upload)"
// @Success 200 "Pause registered"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Source not found"
// @Router /public/api/resources/pause [post]
func publicPauseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.ShareType != "upload" && !d.share.AllowCreate {
return http.StatusForbidden, fmt.Errorf("pausing uploads is not allowed for this share")
}
src, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
sourceName := src.Name
publicPauseCache.Set(publicPauseUploadCacheKey(d.share.Hash, sourceName, d.IndexPath), "1")
return http.StatusOK, nil
}
// resourcePostHandler creates or uploads a new resource.
// @Summary Create or upload a resource
// @Description Creates a new resource or uploads a file at the specified path. Supports file uploads and directory creation.
@@ -437,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)
@@ -506,13 +604,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Create a unique name for the temporary file to avoid collisions.
hasher := md5.New()
hasher.Write([]byte(realPath))
uploadID := hex.EncodeToString(hasher.Sum(nil))
tempFilePath := filepath.Join(settings.Config.Server.CacheDir, "uploads", uploadID)
if err = os.MkdirAll(filepath.Dir(tempFilePath), fileutils.PermDir); err != nil {
logger.Debugf("could not create temp dir: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not create temp dir: %v", err)
}
tempFilePath := fmt.Sprintf("%s.%s.uploading.tmp", realPath, hex.EncodeToString(hasher.Sum(nil)))
// Create or open the temporary file
var outFile *os.File
outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, fileutils.PermFile)
@@ -534,6 +626,31 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
chunkSize, err = io.Copy(outFile, r.Body)
if err != nil {
logger.Debugf("could not write chunk to temp file: %v", err)
if truncErr := outFile.Truncate(offset); truncErr != nil {
logger.Debugf("could not truncate temp file after failed chunk (offset=%d): %v", offset, truncErr)
}
_ = outFile.Sync()
gracefulPause := false
if d.share != nil {
k := publicPauseUploadCacheKey(d.share.Hash, source, path)
if _, ok := publicPauseCache.Get(k); ok {
gracefulPause = true
publicPauseCache.Delete(k)
}
} else {
k := pauseUploadCacheKey(source, path)
if _, ok := pauseCache.Get(k); ok {
gracefulPause = true
pauseCache.Delete(k)
}
}
if gracefulPause {
logger.Debugf("chunk upload ended after graceful pause; keeping partial file (source=%s path=%s)", source, path)
return 499, nil
}
_ = os.Remove(tempFilePath)
return http.StatusInternalServerError, fmt.Errorf("could not write chunk to temp file: %v", err)
}
// check if the file is complete
+11 -8
View File
@@ -14,14 +14,14 @@ import (
)
type searchOptions struct {
query string
sources []string
searchScope string
combinedPath map[string]string // source -> combinedPath
sessionId string
largest bool
olderThanUnix int64 // optional; 0 = unset. Modified time must be strictly before this Unix second.
newerThanUnix int64 // optional; 0 = unset. Modified time must be >= this Unix second.
query string
sources []string
searchScope string
combinedPath map[string]string // source -> combinedPath
sessionId string
largest bool
olderThanUnix int64 // optional; 0 = unset. Modified time must be strictly before this Unix second.
newerThanUnix int64 // optional; 0 = unset. Modified time must be >= this Unix second.
}
// searchHandler handles search requests for files based on the provided query.
@@ -143,6 +143,9 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
if err != nil {
return nil, err
}
if scope == "" {
scope = "/"
}
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
+14 -34
View File
@@ -83,7 +83,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
staticURL := config.Server.BaseURL + "public/static"
description := config.Frontend.Description
title := config.Frontend.Name
banner := staticURL + "/pwa-icon-512.png" // Use largest generated icon for best quality
banner := staticURL + "/icons/pwa-icon-512.png" // largest generated PWA icon
disableSidebar := false
// Use custom favicon if configured and validated, otherwise fall back to default
@@ -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,
@@ -214,6 +201,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"jwtAvailable": config.Auth.Methods.JwtAuth.Enabled,
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"ldapAvailable": config.Auth.Methods.LdapAuth.Enabled,
@@ -312,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
@@ -322,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
View File
@@ -3,9 +3,10 @@ package http
import (
"fmt"
"net/http"
"net/url"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
)
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
@@ -21,18 +22,15 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/otp/generate [post]
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
username := r.URL.Query().Get("username")
if username == "" {
username = d.user.Username
}
if username != d.user.Username && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not authorized to generate OTP for this user")
}
targetUser, err := store.Users.Get(username)
err := checkPassword(r, d)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
return http.StatusUnauthorized, err
}
url, err := auth.GenerateOtpForUser(targetUser, store.Users)
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
if getErr != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
}
url, err := auth.GenerateOtpForUser(user, store.Users)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
}
@@ -61,21 +59,15 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if code == "" {
return http.StatusUnauthorized, fmt.Errorf("code is required")
}
targetUser := d.user
username := r.URL.Query().Get("username")
// If admin is verifying for another user
if username != "" && username != d.user.Username && d.user.Permissions.Admin {
var err error
targetUser, err = store.Users.Get(username)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
}
logger.Debugf("Admin %s verifying OTP for user: %s", d.user.Username, targetUser.Username)
err := checkPassword(r, d)
if err != nil {
return http.StatusUnauthorized, err
}
err := auth.VerifyTotpCode(targetUser, code, store.Users)
user, getErr := store.Users.Get(r.URL.Query().Get("username"))
if getErr != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", getErr)
}
err = auth.VerifyTotpCode(user, code, store.Users)
if err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
}
@@ -86,3 +78,32 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// On success, return a simple confirmation.
return renderJSON(w, r, response)
}
func checkPassword(r *http.Request, d *requestContext) error {
providedPassword := r.Header.Get("X-Password")
username := r.URL.Query().Get("username")
if d.user.Permissions.Admin {
username = d.user.Username
}
// url decode
providedPassword, err := url.QueryUnescape(providedPassword)
if err != nil {
return fmt.Errorf("invalid password encoding: %v", err)
}
if providedPassword == "" {
return fmt.Errorf("password is required")
}
user, getErr := store.Users.Get(username)
var passwordHash string
if getErr != nil {
passwordHash = utils.InvalidPasswordHash
} else {
passwordHash = user.Password
}
// always run checkPwd to prevent timing attacks
err = utils.CheckPwd(providedPassword, passwordHash)
if err != nil {
return fmt.Errorf("invalid password or user not found")
}
return nil
}
+90 -4
View File
@@ -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
+2
View File
@@ -24,6 +24,8 @@ func errToStatus(err error) int {
return http.StatusForbidden
case errors.Is(err, libErrors.ErrInvalidRequestParams):
return http.StatusBadRequest
case errors.Is(err, libErrors.ErrIsDirectory):
return http.StatusMethodNotAllowed
default:
return http.StatusInternalServerError
}
+9 -21
View File
@@ -29,7 +29,6 @@ const (
type iconSize struct {
size int
name string
env *string
}
// determineSourceType determines which type of favicon source we have
@@ -153,13 +152,13 @@ func handleRasterFavicon(faviconPath string) ([]byte, error) {
func getIconSizesToGenerate() []iconSize {
return []iconSize{
// Browser favicon (32x32 PNG for Safari/older browsers)
{32, "favicon-32x32.png", nil},
{32, "favicon-32x32.png"},
// PWA icons (PNG required for Apple PWA compatibility)
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
{192, "pwa-icon-192.png"},
{256, "pwa-icon-256.png"}, // Also serves as Windows tile
{512, "pwa-icon-512.png"},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
{180, "apple-touch-icon.png"}, // iOS home screen (required for Apple)
}
}
@@ -181,10 +180,6 @@ func generateIconSizes(sourceData []byte) error {
continue
}
// Update environment variable if specified
if icon.env != nil {
*icon.env = filepath.Join("icons", icon.name)
}
generatedCount++
}
@@ -199,7 +194,7 @@ func generateIconSizes(sourceData []byte) error {
// generateSingleIcon generates a single icon file at the specified size
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
// Create output file
outFile, err := os.Create(outputPath)
@@ -233,18 +228,11 @@ func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
func GeneratePWAIcons() error {
logger.Info("Generating PWA icons...")
// Set PWA icons directory in cache
settings.Env.PWAIconsDir = filepath.Join(settings.Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
settings.Env.PWAIcon192 = "icons/pwa-icon-192.png"
settings.Env.PWAIcon256 = "icons/pwa-icon-256.png"
settings.Env.PWAIcon512 = "icons/pwa-icon-512.png"
cacheDir := settings.PWAIconsCacheDir()
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(settings.Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", settings.Env.PWAIconsDir, err)
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", cacheDir, err)
}
// Load and prepare source data as PNG
sourceData, err := loadFaviconSource()
+9 -19
View File
@@ -1,9 +1,6 @@
package icons
import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
@@ -36,6 +33,9 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
if len(name) > 12 {
shortName = name[:12]
}
if name == "FileBrowser Quantum" {
shortName = "FBQ"
}
return PWAManifest{
Name: name,
@@ -68,7 +68,9 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
}
}
// InitializePWAManifest generates and caches the PWA manifest at startup
// InitializePWAManifest caches the PWA manifest at startup. Icon URLs always point at
// PNGs under public/static/icons/, which GeneratePWAIcons produces from the configured
// favicon (custom SVG uses a raster sidecar as the raster source).
func InitializePWAManifest() {
config := &settings.Config
staticURL := config.Server.BaseURL + "public/static"
@@ -76,21 +78,9 @@ func InitializePWAManifest() {
description := config.Frontend.Description
defaultThemeColor := "#455a64"
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
favicon := staticURL + "/favicon"
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
pwaIcon192 = staticURL + "/icons/pwa-icon-192.png"
pwaIcon256 = staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 = staticURL + "/icons/pwa-icon-512.png"
}
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
CachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
}
+29 -29
View File
@@ -16,44 +16,26 @@ func TestFetchExtendedAttributes(t *testing.T) {
tests := []struct {
name string
adjustedPath string
combinedPath string
files []os.FileInfo
opts Options
expectHasPreview bool
expectMapNotEmpty bool
}{
{
name: "SkipExtendedAttrs returns empty",
adjustedPath: "/test/",
combinedPath: "/test/",
files: []os.FileInfo{},
opts: Options{
SkipExtendedAttrs: true,
Recursive: false,
},
expectHasPreview: false,
name: "SkipExtendedAttrs returns empty",
adjustedPath: "/test/",
opts: Options{SkipExtendedAttrs: true, Recursive: false},
expectMapNotEmpty: false,
},
{
name: "Recursive returns empty",
adjustedPath: "/test/",
combinedPath: "/test/",
files: []os.FileInfo{},
opts: Options{
SkipExtendedAttrs: false,
Recursive: true,
},
expectHasPreview: false,
name: "Recursive returns empty",
adjustedPath: "/test/",
opts: Options{SkipExtendedAttrs: false, Recursive: true},
expectMapNotEmpty: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasPreview, subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.files, tt.opts)
if hasPreview != tt.expectHasPreview {
t.Errorf("expected hasPreview=%v, got %v", tt.expectHasPreview, hasPreview)
}
subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.opts)
if (len(subdirMap) > 0) != tt.expectMapNotEmpty {
t.Errorf("expected mapNotEmpty=%v, got %v", tt.expectMapNotEmpty, len(subdirMap) > 0)
}
@@ -61,6 +43,28 @@ func TestFetchExtendedAttributes(t *testing.T) {
}
}
// TestFetchExtendedAttributes_subdirHasPreviewFromMetadata checks folder hasPreview comes from one GetMetadataInfo (non-shallow).
func TestFetchExtendedAttributes_subdirHasPreviewFromMetadata(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
deepdir, err := idx.db.GetItem("test", "/subdir/deepdir/")
if err != nil || deepdir == nil {
t.Fatalf("deepdir row: err=%v", err)
}
deepdir.HasPreview = true
_ = idx.db.InsertItem("test", "/subdir/deepdir/", deepdir)
subdirMap := idx.fetchExtendedAttributes("/subdir/", Options{
SkipExtendedAttrs: false,
Recursive: false,
})
if !subdirMap["/subdir/deepdir/"] {
t.Errorf("expected hasPreview map for /subdir/deepdir/, got %v", subdirMap)
}
}
// TestGetDirectoryName tests the directory name extraction logic
func TestGetDirectoryName(t *testing.T) {
tests := []struct {
@@ -210,10 +214,6 @@ func TestProcessDirectoryItem(t *testing.T) {
return
}
if itemInfo == nil {
t.Fatalf("expected non-nil itemInfo")
}
if size != tt.expectSize {
t.Errorf("expected size=%d, got %d", tt.expectSize, size)
}
+52 -107
View File
@@ -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
+7 -5
View File
@@ -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)
}
+5 -12
View File
@@ -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()
+1
View File
@@ -282,6 +282,7 @@ var model3DMimeType = map[string]string{
".gltf": "model/gltf+json",
".glb": "model/gltf-binary",
".obj": "model/obj",
".fbx": "model/fbx",
".stl": "model/stl",
".ply": "model/ply",
".dae": "model/vnd.collada+xml",
+2 -1
View File
@@ -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
@@ -56,6 +58,5 @@ type ExtendedFileInfo struct {
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
}
+3
View File
@@ -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)
}
})
}
}
+10 -4
View File
@@ -27,6 +27,8 @@ func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
if !strings.HasPrefix(i.Type, "video") {
return
}
// could be myvideo.mov
ext := filepath.Ext(i.Name)
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
@@ -34,25 +36,29 @@ func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
var externalSubs []utils.SubtitleTrack
if parentInfo != nil && parentInfo.Files != nil {
for _, f := range parentInfo.Files {
// handle case myvideo.srt
fileExt := filepath.Ext(f.Name)
fileBase := strings.TrimSuffix(f.Name, fileExt)
fileBaseWithoutExt := strings.TrimSuffix(f.Name, fileExt)
// handle case myvideo.en.srt
langExt := filepath.Ext(fileBaseWithoutExt)
fileBaseWithoutExt2 := strings.TrimSuffix(fileBaseWithoutExt, langExt)
matches := fileBaseWithoutExt == baseWithoutExt || fileBaseWithoutExt2 == baseWithoutExt
// Check if this file has the same base name and a subtitle extension
if fileBase == baseWithoutExt && SubtitleExts[strings.ToLower(fileExt)] {
if matches && SubtitleExts[strings.ToLower(fileExt)] {
track := utils.SubtitleTrack{
Name: f.Name,
Embedded: false,
}
// Try to infer language from filename patterns like "video.en.srt"
parts := strings.Split(fileBase, ".")
parts := strings.Split(fileBaseWithoutExt, ".")
if len(parts) > 1 {
lastPart := parts[len(parts)-1]
if len(lastPart) == 2 || len(lastPart) == 3 {
track.Language = lastPart
}
}
externalSubs = append(externalSubs, track)
}
}
+58 -34
View File
@@ -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
@@ -337,25 +351,19 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
scannerInfos = append(scannerInfos, &ScannerInfo{
Path: scanner.scanPath,
CurrentSchedule: scanner.currentSchedule,
Stats: Stats{
LastScanned: scanner.lastScanned,
Complexity: scanner.complexity,
QuickScanTime: scanner.quickScanTime,
FullScanTime: scanner.fullScanTime,
NumDirs: scanner.numDirs,
NumFiles: scanner.numFiles,
},
Stats: Stats{
LastScanned: scanner.lastScanned,
Complexity: scanner.complexity,
QuickScanTime: scanner.quickScanTime,
FullScanTime: scanner.fullScanTime,
NumDirs: scanner.numDirs,
NumFiles: scanner.numFiles,
},
})
}
reducedIdx := idx.ReducedIndex
// Compute DiskUsed from database (total size of all files indexed)
diskUsed, err := idx.db.GetTotalSize(idx.Name)
if err != nil {
logger.Errorf("Failed to get total size for index %s: %v", sourceName, err)
diskUsed = 0
}
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()
@@ -370,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
}
+2 -2
View File
@@ -1,6 +1,6 @@
.card,
#listingView.gallery .item,
#listingView.normal .item,
.listing-item,
.floating-window,
.button,
button,
.input,
+406 -6
View File
@@ -1074,6 +1074,69 @@ const docTemplate = `{
}
}
},
"/api/media/metadata": {
"get": {
"description": "Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Directory with media metadata",
"parameters": [
{
"type": "string",
"description": "Path to the directory or file",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name",
"name": "source",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "When true, include embedded album art bytes in audio metadata",
"name": "albumArt",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/media/subtitles": {
"get": {
"description": "Returns raw subtitle content from external files or embedded streams",
@@ -1384,7 +1447,7 @@ const docTemplate = `{
},
{
"type": "string",
"description": "Skip extended attributes for faster retrieval, no hasPreview",
"description": "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction",
"name": "skipExtendedAttrs",
"in": "query"
},
@@ -1403,7 +1466,7 @@ const docTemplate = `{
},
{
"type": "string",
"description": "Extract audio/video metadata if true",
"description": "When true, run audio/video metadata extraction, subtitles, and directory media batch processing",
"name": "metadata",
"in": "query"
},
@@ -2029,6 +2092,69 @@ const docTemplate = `{
}
}
},
"/api/resources/pause": {
"post": {
"description": "Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Register graceful chunked upload pause",
"parameters": [
{
"type": "string",
"description": "Destination path (same as upload)",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name (same as upload)",
"name": "source",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/resources/preview": {
"get": {
"description": "Returns a preview image based on the requested path and size.",
@@ -2950,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"
],
@@ -2971,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",
@@ -2994,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": {
@@ -3015,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"
],
@@ -3027,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",
@@ -3053,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": {
@@ -3065,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"
],
@@ -3083,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": {
@@ -3130,6 +3301,46 @@ const docTemplate = `{
}
}
},
"/public/api/media/metadata": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Directory with media metadata (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"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": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -3740,6 +3951,60 @@ const docTemplate = `{
}
}
},
"/public/api/resources/pause": {
"post": {
"description": "Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Register graceful chunked upload pause (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share (same as upload)",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources/preview": {
"get": {
"description": "Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.",
@@ -4441,6 +4706,99 @@ const docTemplate = `{
}
}
},
"iteminfo.ExtendedFileInfo": {
"type": "object",
"properties": {
"checksums": {
"description": "checksums for the file",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"content": {
"description": "text content of a file, if requested",
"type": "string"
},
"files": {
"description": "files in the directory with optional metadata",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ExtendedItemInfo"
}
},
"folders": {
"description": "folders in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ItemInfo"
}
},
"hasPreview": {
"description": "whether the file has a thumbnail preview",
"type": "boolean"
},
"hash": {
"description": "hash for the file -- used for sharing",
"type": "string"
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"isShared": {
"description": "whether the file or folder is shared",
"type": "boolean"
},
"metadata": {
"description": "media metadata for audio/video files (includes duration)",
"allOf": [
{
"$ref": "#/definitions/iteminfo.MediaMetadata"
}
]
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"onlyOfficeId": {
"description": "id for onlyoffice files",
"type": "string"
},
"path": {
"description": "path scoped to the associated index",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"source": {
"description": "associated index source for the file",
"type": "string"
},
"subtitles": {
"description": "subtitles for video files",
"type": "array",
"items": {
"$ref": "#/definitions/utils.SubtitleTrack"
}
},
"token": {
"description": "token for the file -- used for sharing",
"type": "string"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
},
"iteminfo.ExtendedItemInfo": {
"type": "object",
"properties": {
@@ -4862,6 +5220,15 @@ const docTemplate = `{
"description": "enable to always create a new indexing database on startup.",
"type": "boolean"
},
"startupIntegrityCheck": {
"description": "the method used to check the integrity of the index database on startup (default: quickCheck)",
"type": "string",
"enum": [
"quickCheck",
"probe",
"off"
]
},
"walMode": {
"description": "enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.",
"type": "boolean"
@@ -4915,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": {
@@ -6512,6 +6879,10 @@ const docTemplate = `{
"$ref": "#/definitions/users.SourceScope"
}
},
"showCopyPath": {
"description": "show copy path action in the context menu",
"type": "boolean"
},
"showFirstLogin": {
"type": "boolean"
},
@@ -6568,6 +6939,35 @@ const docTemplate = `{
"type": "string"
}
}
},
"utils.SubtitleTrack": {
"type": "object",
"properties": {
"codec": {
"description": "codec name for embedded subtitles",
"type": "string"
},
"embedded": {
"description": "true for embedded subtitles, false for external files",
"type": "boolean"
},
"index": {
"description": "stream index for embedded subtitles (nil for external)",
"type": "integer"
},
"language": {
"description": "language code",
"type": "string"
},
"name": {
"description": "filename for external, or descriptive name for embedded",
"type": "string"
},
"title": {
"description": "title/description",
"type": "string"
}
}
}
}
}`
+406 -6
View File
@@ -1063,6 +1063,69 @@
}
}
},
"/api/media/metadata": {
"get": {
"description": "Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Directory with media metadata",
"parameters": [
{
"type": "string",
"description": "Path to the directory or file",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name",
"name": "source",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "When true, include embedded album art bytes in audio metadata",
"name": "albumArt",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/media/subtitles": {
"get": {
"description": "Returns raw subtitle content from external files or embedded streams",
@@ -1373,7 +1436,7 @@
},
{
"type": "string",
"description": "Skip extended attributes for faster retrieval, no hasPreview",
"description": "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction",
"name": "skipExtendedAttrs",
"in": "query"
},
@@ -1392,7 +1455,7 @@
},
{
"type": "string",
"description": "Extract audio/video metadata if true",
"description": "When true, run audio/video metadata extraction, subtitles, and directory media batch processing",
"name": "metadata",
"in": "query"
},
@@ -2018,6 +2081,69 @@
}
}
},
"/api/resources/pause": {
"post": {
"description": "Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Register graceful chunked upload pause",
"parameters": [
{
"type": "string",
"description": "Destination path (same as upload)",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name (same as upload)",
"name": "source",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/resources/preview": {
"get": {
"description": "Returns a preview image based on the requested path and size.",
@@ -2939,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"
],
@@ -2960,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",
@@ -2983,6 +3115,15 @@
}
}
},
"401": {
"description": "Unauthorized - invalid or missing actor password when required",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
@@ -3004,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"
],
@@ -3016,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",
@@ -3042,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": {
@@ -3054,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"
],
@@ -3072,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": {
@@ -3119,6 +3290,46 @@
}
}
},
"/public/api/media/metadata": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Directory with media metadata (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"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": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -3729,6 +3940,60 @@
}
}
},
"/public/api/resources/pause": {
"post": {
"description": "Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Register graceful chunked upload pause (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share (same as upload)",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources/preview": {
"get": {
"description": "Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.",
@@ -4430,6 +4695,99 @@
}
}
},
"iteminfo.ExtendedFileInfo": {
"type": "object",
"properties": {
"checksums": {
"description": "checksums for the file",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"content": {
"description": "text content of a file, if requested",
"type": "string"
},
"files": {
"description": "files in the directory with optional metadata",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ExtendedItemInfo"
}
},
"folders": {
"description": "folders in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ItemInfo"
}
},
"hasPreview": {
"description": "whether the file has a thumbnail preview",
"type": "boolean"
},
"hash": {
"description": "hash for the file -- used for sharing",
"type": "string"
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"isShared": {
"description": "whether the file or folder is shared",
"type": "boolean"
},
"metadata": {
"description": "media metadata for audio/video files (includes duration)",
"allOf": [
{
"$ref": "#/definitions/iteminfo.MediaMetadata"
}
]
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"onlyOfficeId": {
"description": "id for onlyoffice files",
"type": "string"
},
"path": {
"description": "path scoped to the associated index",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"source": {
"description": "associated index source for the file",
"type": "string"
},
"subtitles": {
"description": "subtitles for video files",
"type": "array",
"items": {
"$ref": "#/definitions/utils.SubtitleTrack"
}
},
"token": {
"description": "token for the file -- used for sharing",
"type": "string"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
},
"iteminfo.ExtendedItemInfo": {
"type": "object",
"properties": {
@@ -4851,6 +5209,15 @@
"description": "enable to always create a new indexing database on startup.",
"type": "boolean"
},
"startupIntegrityCheck": {
"description": "the method used to check the integrity of the index database on startup (default: quickCheck)",
"type": "string",
"enum": [
"quickCheck",
"probe",
"off"
]
},
"walMode": {
"description": "enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.",
"type": "boolean"
@@ -4904,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": {
@@ -6501,6 +6868,10 @@
"$ref": "#/definitions/users.SourceScope"
}
},
"showCopyPath": {
"description": "show copy path action in the context menu",
"type": "boolean"
},
"showFirstLogin": {
"type": "boolean"
},
@@ -6557,6 +6928,35 @@
"type": "string"
}
}
},
"utils.SubtitleTrack": {
"type": "object",
"properties": {
"codec": {
"description": "codec name for embedded subtitles",
"type": "string"
},
"embedded": {
"description": "true for embedded subtitles, false for external files",
"type": "boolean"
},
"index": {
"description": "stream index for embedded subtitles (nil for external)",
"type": "integer"
},
"language": {
"description": "language code",
"type": "string"
},
"name": {
"description": "filename for external, or descriptive name for embedded",
"type": "string"
},
"title": {
"description": "title/description",
"type": "string"
}
}
}
}
}
+298 -7
View File
@@ -359,6 +359,72 @@ definitions:
type:
type: string
type: object
iteminfo.ExtendedFileInfo:
properties:
checksums:
additionalProperties:
type: string
description: checksums for the file
type: object
content:
description: text content of a file, if requested
type: string
files:
description: files in the directory with optional metadata
items:
$ref: '#/definitions/iteminfo.ExtendedItemInfo'
type: array
folders:
description: folders in the directory
items:
$ref: '#/definitions/iteminfo.ItemInfo'
type: array
hasPreview:
description: whether the file has a thumbnail preview
type: boolean
hash:
description: hash for the file -- used for sharing
type: string
hidden:
description: whether the file is hidden
type: boolean
isShared:
description: whether the file or folder is shared
type: boolean
metadata:
allOf:
- $ref: '#/definitions/iteminfo.MediaMetadata'
description: media metadata for audio/video files (includes duration)
modified:
description: modification time
type: string
name:
description: name of the file
type: string
onlyOfficeId:
description: id for onlyoffice files
type: string
path:
description: path scoped to the associated index
type: string
size:
description: length in bytes for regular files
type: integer
source:
description: associated index source for the file
type: string
subtitles:
description: subtitles for video files
items:
$ref: '#/definitions/utils.SubtitleTrack'
type: array
token:
description: token for the file -- used for sharing
type: string
type:
description: type of the file, either "directory" or a file mimetype
type: string
type: object
iteminfo.ExtendedItemInfo:
properties:
hasPreview:
@@ -685,6 +751,14 @@ definitions:
disableReuse:
description: enable to always create a new indexing database on startup.
type: boolean
startupIntegrityCheck:
description: 'the method used to check the integrity of the index database
on startup (default: quickCheck)'
enum:
- quickCheck
- probe
- "off"
type: string
walMode:
description: enable the more complex WAL journaling mode. Slower, more memory
usage, but better for deployments with constant user activity.
@@ -727,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
@@ -1910,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:
@@ -1953,6 +2030,27 @@ definitions:
description: 'view mode to use: eg. normal, list, grid, or compact'
type: string
type: object
utils.SubtitleTrack:
properties:
codec:
description: codec name for embedded subtitles
type: string
embedded:
description: true for embedded subtitles, false for external files
type: boolean
index:
description: stream index for embedded subtitles (nil for external)
type: integer
language:
description: language code
type: string
name:
description: filename for external, or descriptive name for embedded
type: string
title:
description: title/description
type: string
type: object
info:
contact: {}
paths:
@@ -2670,6 +2768,49 @@ paths:
summary: List API tokens
tags:
- Auth
/api/media/metadata:
get:
consumes:
- application/json
description: Same ExtendedFileInfo as resources GET with metadata=true (typically
used for directories).
parameters:
- description: Path to the directory or file
in: query
name: path
required: true
type: string
- description: Source name
in: query
name: source
required: true
type: string
- description: When true, include embedded album art bytes in audio metadata
in: query
name: albumArt
type: boolean
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/iteminfo.ExtendedFileInfo'
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
summary: Directory with media metadata
tags:
- Resources
/api/media/subtitles:
get:
consumes:
@@ -2915,7 +3056,8 @@ paths:
name: path
required: true
type: string
- description: Skip extended attributes for faster retrieval, no hasPreview
- description: When true, omit index-level extended fields (e.g. hasPreview);
does not disable ffmpeg/media extraction
in: query
name: skipExtendedAttrs
type: string
@@ -2928,7 +3070,8 @@ paths:
in: query
name: content
type: string
- description: Extract audio/video metadata if true
- description: When true, run audio/video metadata extraction, subtitles, and
directory media batch processing
in: query
name: metadata
type: string
@@ -3339,6 +3482,50 @@ paths:
summary: Get directory items
tags:
- Resources
/api/resources/pause:
post:
consumes:
- application/json
description: Sets a short-lived server flag for the given source and path. When
the active chunk POST then fails (e.g. client abort), the server trims the
incomplete chunk but keeps the temp partial file for resume.
parameters:
- description: Destination path (same as upload)
in: query
name: path
required: true
type: string
- description: Source name (same as upload)
in: query
name: source
required: true
type: string
produces:
- application/json
responses:
"200":
description: Pause registered
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Source not found
schema:
additionalProperties:
type: string
type: object
summary: Register graceful chunked upload pause
tags:
- Resources
/api/resources/preview:
get:
consumes:
@@ -3949,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:
@@ -4017,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
@@ -4038,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:
@@ -4050,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
@@ -4060,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
@@ -4075,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:
@@ -4103,6 +4330,33 @@ paths:
schema:
$ref: '#/definitions/http.HttpResponse'
summary: Health Check
/public/api/media/metadata:
get:
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share
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:
"200":
description: OK
schema:
$ref: '#/definitions/iteminfo.ExtendedFileInfo'
summary: Directory with media metadata (public share)
tags:
- Shares
/public/api/resources:
get:
consumes:
@@ -4528,6 +4782,43 @@ paths:
summary: Get directory items (public share)
tags:
- Shares
/public/api/resources/pause:
post:
consumes:
- application/json
description: Same as authenticated pause, for upload shares. Uses hash and path
query parameters like other public resource APIs.
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share (same as upload)
in: query
name: path
required: true
type: string
produces:
- application/json
responses:
"200":
description: Pause registered
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Source not found
schema:
additionalProperties:
type: string
type: object
summary: Register graceful chunked upload pause (public share)
tags:
- Shares
/public/api/resources/preview:
get:
consumes:
+240 -308
View File
@@ -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,12 +35,13 @@
"@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",
"fs-extra": "^11.3.0",
"jsdom": "^25.0.1",
"typescript": "^6.0.0",
"vite": "^6.2.0",
"vite-plugin-checker": "^0.10.3",
"vite-plugin-compression2": "^1.0.0",
@@ -1126,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"
@@ -1165,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"
],
@@ -1179,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"
],
@@ -1193,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"
],
@@ -1207,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"
],
@@ -1221,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"
],
@@ -1235,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"
],
@@ -1249,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"
],
@@ -1263,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"
],
@@ -1277,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"
],
@@ -1291,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"
],
@@ -1305,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"
],
@@ -1319,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"
],
@@ -1333,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"
],
@@ -1347,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"
],
@@ -1361,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"
],
@@ -1375,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"
],
@@ -1389,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"
],
@@ -1403,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"
],
@@ -1417,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"
],
@@ -1431,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"
],
@@ -1445,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"
],
@@ -1459,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"
],
@@ -1473,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"
],
@@ -1487,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"
],
@@ -1501,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"
],
@@ -1550,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": {
@@ -1959,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": {
@@ -2076,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"
@@ -2164,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",
@@ -2185,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": {
@@ -2268,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": {
@@ -2318,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"
@@ -2444,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",
@@ -2659,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"
@@ -2702,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",
@@ -2741,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"
@@ -3493,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",
@@ -3709,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",
@@ -3782,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"
@@ -4169,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": {
@@ -4267,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": {
@@ -4350,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",
@@ -4420,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": {
@@ -4454,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"
@@ -4817,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": {
@@ -4849,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"
@@ -4868,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": {
@@ -4894,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",
@@ -4952,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",
@@ -4968,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"
@@ -5093,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": {
@@ -5109,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"
}
},
@@ -5194,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",
@@ -5513,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",
@@ -5541,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"
@@ -5699,12 +5642,11 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"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",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -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": "*"
+6 -3
View File
@@ -16,6 +16,8 @@
"lint:fix": "eslint --fix --ext .js,.vue,.ts src/",
"i18n:sync": "node ./scripts/sync-translations.js",
"i18n:check": "node ./scripts/sync-translations.js --check",
"i18n:enforce-order": "node scripts/sync-translations.js --enforce-order",
"i18n:cleanup": "node scripts/sync-translations.js --cleanup",
"format": "prettier --write .",
"test": "vitest run "
},
@@ -23,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",
@@ -42,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",
@@ -51,12 +53,13 @@
"@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",
"fs-extra": "^11.3.0",
"jsdom": "^25.0.1",
"typescript": "^6.0.0",
"vite": "^6.2.0",
"vite-plugin-checker": "^0.10.3",
"vite-plugin-compression2": "^1.0.0",
+3 -2
View File
@@ -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,
},
],
+2 -1
View File
@@ -58,6 +58,7 @@ server:
cacheSizeMB: 32 # size of the SQLite cache in MB
walMode: false # enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
disableReuse: false # enable to always create a new indexing database on startup.
startupIntegrityCheck: "quickCheck" # the method used to check the integrity of the index database on startup (default: quickCheck) validate:omitempty,oneof=quickCheck probe off
disableWebDAV: false # disable webdav support (default: false)
auth:
tokenExpirationHours: 2 # time in hours each web UI session token is valid for. Default is 2 hours.
@@ -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".
+11
View File
@@ -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;
}
}
Binary file not shown.
Binary file not shown.
+1 -2
View File
@@ -13,8 +13,7 @@
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-normal-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-medium-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-bold-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-icons.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols-outlined.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols.woff2" as="font" type="font/woff2" crossorigin>
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
+119 -52
View File
@@ -8,6 +8,8 @@ import * as deepl from 'deepl-node';
// Parse command line arguments
const args = process.argv.slice(2);
const checkOnly = args.includes('--check') || args.includes('-c');
const enforceOrder = args.includes('--enforce-order'); // When using this will force the order of eng, will not perform new translations. If we move keys to a different block will count as new translations and will delete it from all the languages
const cleanupOnly = args.includes('--cleanup'); // Will only delete keys that not longer exist in eng, no translation or reorder, just deletion.
// --- Configuration ---
const __filename = fileURLToPath(import.meta.url);
@@ -19,13 +21,14 @@ const targetLocaleFiles = glob.sync(path.join(localesDir, '*.json'))
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
.filter(file => path.basename(file) !== 'is.json'); // Exclude Icelandic - DeepL doesn't support it
const requireApiKey = !checkOnly && !enforceOrder && !cleanupOnly;
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
if (!checkOnly && !DEEPL_API_KEY) {
if (requireApiKey && !DEEPL_API_KEY) {
console.error("❌ Missing DEEPL_API_KEY in environment.");
process.exit(1);
}
const translator = checkOnly ? null : new deepl.Translator(DEEPL_API_KEY);
const translator = requireApiKey ? new deepl.Translator(DEEPL_API_KEY) : null;
const deeplLangMap = {
'zh-cn': 'ZH-HANS',
@@ -95,8 +98,23 @@ async function translateText(text, targetLanguage, keyPath = '') {
}
}
// Reorder objects to match eng (master lang) key order
function reorderObject(obj, masterObj) {
// If obj is not an object, return as is
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj;
const newObj = {};
for (const key in masterObj) {
if (Object.prototype.hasOwnProperty.call(masterObj, key)) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = reorderObject(obj[key], masterObj[key]);
}
}
}
return newObj;
}
// --- Recursive key processor ---
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = []) {
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = [], doEnforceOrder, doCleanupOnly = false) {
let changesMade = false;
let meaningfulChanges = 0; // Only count meaningful changes
@@ -109,19 +127,17 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
const masterValue = masterObj[key];
// Special handling for "languages" key - always copy the entire object from master
if (key === 'languages' && currentPathParts.length === 0) {
if (checkOnly) {
// Don't print or count languages object copying - it's routine
} else {
if (key === 'languages' && currentPathParts.length === 0 && !doCleanupOnly) {
if (!checkOnly && !doEnforceOrder) {
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
}
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
changesMade = true;
continue;
}
if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
if (!targetObj[key] || typeof targetObj[key] !== 'object') {
if (!doCleanupOnly && (!targetObj[key] || typeof targetObj[key] !== 'object')) {
if (checkOnly) {
console.log(`Would create missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
meaningfulChanges++; // Creating new object structures is meaningful
@@ -133,41 +149,55 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
}
changesMade = true;
}
const result = await processKeys(masterValue, targetObj[key], targetLangCode, currentPathPartsNext);
if (result == "UNSUPPORTED") {
if (targetObj[key] && typeof targetObj[key] === 'object') {
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
doEnforceOrder, doCleanupOnly);
if (result == "UNSUPPORTED") {
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
return "UNSUPPORTED";
}
if (typeof result === 'number') {
meaningfulChanges += result;
changesMade = true;
} else if (result) {
changesMade = true;
}
}
if (typeof result === 'number') {
meaningfulChanges += result;
changesMade = true;
} else if (result) {
changesMade = true;
}
} else if (typeof masterValue === 'string') {
if (!targetObj.hasOwnProperty(key) || targetObj[key] === '' || targetObj[key] === null) {
if (checkOnly) {
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
meaningfulChanges++; // Translation is meaningful
} else {
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result == "") {
return "UNSUPPORTED";
} else if (!doCleanupOnly) {
// Only handle non‑object values
if (typeof masterValue === 'string') {
const existingTargetValue = targetObj[key];
const isMissing = !Object.prototype.hasOwnProperty.call(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
if (isMissing) {
if (checkOnly) {
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
meaningfulChanges++; // Translation is meaningful
} else if (doEnforceOrder) {
// Skip translations for new keys
console.log(`Skipping new key "${currentKeyPath}" for ${targetLangCode}.json`);
} else {
// Sync translation
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result == "") {
return "UNSUPPORTED";
}
targetObj[key] = result;
changesMade = true;
}
targetObj[key] = result;
}
changesMade = true;
}
} else {
if (!targetObj.hasOwnProperty(key)) {
if (checkOnly) {
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
meaningfulChanges++; // Copying non-string values is meaningful
} else {
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
targetObj[key] = masterValue;
} else {
if (!Object.prototype.hasOwnProperty.call(targetObj, key)) {
if (checkOnly) {
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
meaningfulChanges++; // Copying non-string values is meaningful
} else {
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
targetObj[key] = masterValue;
}
changesMade = true;
}
changesMade = true;
}
}
}
@@ -177,7 +207,7 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
const keysToRemove = [];
for (const key in targetObj) {
if (Object.prototype.hasOwnProperty.call(targetObj, key)) {
if (!masterObj.hasOwnProperty(key)) {
if (!Object.prototype.hasOwnProperty.call(masterObj, key)) {
keysToRemove.push(key);
}
}
@@ -200,9 +230,17 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
// --- Main synchronization ---
async function syncAllTranslations() {
const isSync = !checkOnly && !enforceOrder && !cleanupOnly;
const isEnforceOrder = enforceOrder && !cleanupOnly;
const isCleanup = cleanupOnly;
if (checkOnly) {
console.log("--- Checking for translation changes (no translations will be performed) ---");
} else {
} else if (isCleanup) {
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
} else if (isEnforceOrder) {
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
} else if (isSync) {
console.warn("--- Using DeepL API for translation ---");
}
@@ -220,38 +258,61 @@ async function syncAllTranslations() {
for (const targetFile of targetLocaleFiles) {
const targetLangCode = path.basename(targetFile, '.json');
let targetContent = {};
let originalTargetContent = null;
let fileExisted = await fs.pathExists(targetFile);
if (fileExisted) {
try {
targetContent = await fs.readJson(targetFile);
originalTargetContent = await fs.readJson(targetFile);
targetContent = JSON.parse(JSON.stringify(originalTargetContent)); // start from original
} catch (e) {
console.warn(`Warning: Could not parse ${targetFile}. Starting fresh. Error: ${e.message}`);
targetContent = {};
originalTargetContent = null;
}
} else {
if (isCleanup) continue;
console.log(`\nTarget file ${targetFile} not found. Will create for language: ${targetLangCode}.`);
}
const result = await processKeys(masterContent, targetContent, targetLangCode);
const result = await processKeys(masterContent, targetContent, targetLangCode, [], isEnforceOrder, isCleanup);
let reorderedContent = targetContent;
let orderChanged = false;
if (isEnforceOrder && originalTargetContent && !checkOnly) {
reorderedContent = reorderObject(targetContent, masterContent);
// Compare the reordered content with the original to see if order changed
const origStr = JSON.stringify(originalTargetContent);
const reorderedStr = JSON.stringify(reorderedContent);
if (origStr !== reorderedStr) {
orderChanged = true;
console.log(`- Reordered keys in ${targetLangCode}.json`);
}
}
if (checkOnly) {
let wouldChange = false;
if (typeof result === 'number' && result > 0) {
meaningfulChanges += result;
hasMeaningfulChanges = true;
wouldChange = true;
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
} else if (!fileExisted) {
meaningfulChanges++;
hasMeaningfulChanges = true;
}
if (!fileExisted && !isCleanup) {
wouldChange = true;
console.log(`Would create new file for ${targetLangCode}.json`);
} else {
// Only print if there are meaningful changes
}
if (wouldChange) {
hasMeaningfulChanges = true;
}
} else {
if (result || !fileExisted) {
const needsWrite = result || orderChanged || (!isCleanup && !fileExisted);
if (needsWrite) {
const finalContent = (isEnforceOrder && orderChanged) ? reorderedContent : targetContent;
try {
await fs.writeJson(targetFile, targetContent, { spaces: 2 });
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
await fs.writeJson(targetFile, finalContent, { spaces: 2 });
if (!isCleanup && !isEnforceOrder) {
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
}
} catch (error) {
console.error(`Error writing to ${targetFile}:`, error);
}
@@ -270,7 +331,13 @@ async function syncAllTranslations() {
return 0; // Exit code 0 for no meaningful changes
}
} else {
console.log('\n✅ Translation synchronization complete (via DeepL).');
if (isCleanup) {
console.log('\n✅ Cleanup complete (obsolete keys removed).');
} else if (isEnforceOrder) {
console.log('\n✅ Enforce order complete.');
} else {
console.log('\n✅ Translation synchronization complete (via DeepL).');
}
return 0;
}
}
+20 -4
View File
@@ -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
}
}
+49 -2
View File
@@ -1,6 +1,7 @@
import { fetchURL } from "./utils";
import { fetchURL, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { state } from "@/store";
// GET /api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
@@ -18,4 +19,50 @@ export async function getSubtitleContent(source, path, subtitleName, embedded =
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
throw err
}
}
// 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
/** @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);
const response = await fetch(apiPath, {
headers: { "X-SHARE-PASSWORD": password || "" },
});
if (!response.ok) {
const error = new Error(response.statusText);
let data = null;
try {
data = await response.json();
} catch (e) {
// ignore
}
if (data?.message) {
error.message = data.message;
}
/** @type {any} */ (error).status = response.status;
throw error;
}
const data = await response.json();
return adjustedData(data);
}
+69 -4
View File
@@ -47,6 +47,31 @@ export async function getItems(source, path, only = "") {
}
}
/**
* Registers a graceful pause for the active chunked upload at this path.
* Call immediately before aborting the chunk XHR so the server keeps partial data.
*/
export async function signalUploadPause(source, path, shareHash = null) {
if (shareHash) {
const apiPath = getPublicApiPath('resources/pause', {
hash: shareHash,
path: path,
})
const headers = {}
const sharePassword = localStorage.getItem('sharepass:' + shareHash)
if (sharePassword) {
headers['X-SHARE-PASSWORD'] = sharePassword
}
await fetchURL(apiPath, { method: 'POST', headers })
return
}
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
const apiPath = getApiPath('resources/pause', { path, source })
await fetchURL(apiPath, { method: 'POST' })
}
async function resourceAction(source, path, method, content) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
@@ -194,6 +219,19 @@ export async function download(format, files, shareHash = "") {
}, 100)
}
/** Best-effort text from a failed download response (consumes body). */
async function readDownloadErrorBody(response) {
try {
const text = await response.text()
if (text && text.length > 0) {
return text.length > 300 ? `${text.slice(0, 300)}…` : text
}
} catch {
// ignore
}
return ''
}
async function downloadChunked(file, shareHash = "") {
const chunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
@@ -259,7 +297,18 @@ async function downloadChunked(file, shareHash = "") {
})
if (!response.ok && response.status !== 206) {
throw new Error(`Failed to download chunk: ${response.statusText}`)
const body = (await readDownloadErrorBody(response)).trim()
const fromHttp =
body ||
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(" ").trim()
throw new Error(fromHttp || "Request failed")
}
if (!response.body) {
throw new Error(
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(" ").trim() ||
"No response body"
)
}
// Track progress within the chunk using ReadableStream
@@ -303,6 +352,10 @@ async function downloadChunked(file, shareHash = "") {
throw readError; // Re-throw other errors
}
if (chunkLoaded < expectedChunkSize) {
throw new Error("Connection closed before the full chunk was received")
}
// Combine chunk parts into single ArrayBuffer
const chunk = new Uint8Array(chunkLoaded);
let position = 0;
@@ -355,8 +408,9 @@ async function downloadChunked(file, shareHash = "") {
// Don't throw error or show notification for user-initiated cancellation
return;
}
downloadManager.setError(downloadId, error.message || 'Download failed')
notify.showError(`Chunked download failed: ${error.message}`)
const message =
error instanceof Error ? error.message || "Unknown error" : String(error)
downloadManager.setError(downloadId, message)
throw error
}
}
@@ -408,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);
}
};
+2 -2
View File
@@ -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
View File
@@ -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
}
}
+2 -2
View File
@@ -30,8 +30,8 @@
:title="label"
class="action no-select"
>
<i v-if="icon == 'table_rows_narrow'" class="material-symbols-outlined">{{ icon }}</i>
<i v-else class="material-icons">{{ 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>
<span v-if="counter > 0" class="counter">{{ counter }}</span>
+137 -53
View File
@@ -10,47 +10,66 @@
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-icons">add</i>
<i v-else class="material-icons">arrow_back</i>
<i v-if="!showCreate" class="material-symbols">add</i>
<i v-else class="material-symbols">arrow_back</i>
</div>
<div
v-if="selectedCount > 0"
@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"
@@ -84,16 +97,22 @@
/>
<action
v-if="showRename"
icon="mode_edit"
icon="edit"
:label="$t('general.rename')"
@action="showRenamePrompt"
/>
<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,13 +171,13 @@
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"/>
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('general.openFile')" @action="goToRaw()" />
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
<action v-if="isPreview && permissions.modify" icon="mode_edit" :label="$t('general.rename')" @action="showRenamePromptForPreview" />
<action v-if="isPreview && permissions.modify" icon="edit" :label="$t('general.rename')" @action="showRenamePromptForPreview" />
<action v-if="showWatch" icon="visibility" :label="$t('buttons.watchFile')" @action="watchFile()" />
<action v-if="hasDownload" icon="file_download" :label="$t('general.download')" @action="startDownload" />
<action v-if="showUnarchiveInOverflow" icon="folder_open" :label="$t('prompts.unarchive')" @action="showUnarchivePromptFromPreview" />
@@ -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>
+4 -4
View File
@@ -19,10 +19,10 @@
}"
>
<!-- Close button - always present on every notification, separate from optional buttons array -->
<i class="material-icons" @click="closeNotification(notification.id)">close</i>
<i class="material-symbols" @click="closeNotification(notification.id)">close</i>
<div class="notification-content-wrapper">
<div class="notification-header">
<i v-if="notification.icon" class="material-icons notification-icon">
<i v-if="notification.icon" class="material-symbols notification-icon">
{{ notification.icon }}
</i>
<div class="notification-message">{{ notification.message }}</div>
@@ -255,13 +255,13 @@ export default {
border-color: rgba(255, 255, 255, 0.5);
}
.notification-item > .material-icons:first-child {
.notification-item > .material-symbols:first-child {
cursor: pointer;
font-size: 1.75em;
opacity: 0.8;
}
.notification-item > .material-icons:first-child:hover {
.notification-item > .material-symbols:first-child:hover {
opacity: 1;
}
+4 -2
View File
@@ -21,7 +21,7 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
{{ displayed_text }}
<i
v-if="helpText && status === 'error'"
class="no-select material-symbols-outlined tooltip-info-icon"
class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
@@ -354,7 +354,9 @@ export default {
opacity: 0.7;
}
.tooltip-info-icon:hover {
.tooltip-info-icon:hover,
.tooltip-info-icon:hover .material-symbols-outlined {
font-variation-settings: 'FILL' 1;
opacity: 1;
}
+37 -22
View File
@@ -5,10 +5,10 @@
<!-- Close button visible when search is active -->
<button v-if="active" class="action" @click="close" :aria-label="$t('general.close')"
:title="$t('general.close')">
<i class="material-icons">close</i>
<i class="material-symbols">close</i>
</button>
<!-- Search icon when search is not active -->
<i v-else class="material-icons">search</i>
<i v-else class="material-symbols">search</i>
<!-- Input field for search -->
<input id="search-input" type="text"
@keyup.exact="keyup" @input="submit" ref="input" :autofocus="active" v-model.trim="value"
@@ -69,11 +69,11 @@
</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>
<i class="no-select material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
<i class="material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
@mouseleave="hideTooltip">
help
</i>
@@ -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);
@@ -381,9 +380,6 @@ export default {
const fullPath = context + '/' + path;
return url.buildItemUrl(source, fullPath, true);
},
getIcon(mimetype) {
return getMaterialIconForType(mimetype);
},
enableOptions() {
this.hiddenOptions = false;
this.toggleOptionButton = [{ label: "Hide Options" }];
@@ -467,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;
@@ -491,7 +486,7 @@ export default {
if (newerUnix !== null) {
dateParams.newerThan = newerUnix;
}
this.ongoing = true;
this.ongoing++;
// Determine which sources to search
let sourcesToSearch = [];
@@ -514,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,
@@ -551,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("/");
@@ -694,12 +709,12 @@ export default {
width: 100%;
}
.search-input-container .material-icons {
.search-input-container .material-symbols {
font-size: 1.25em;
color: rgba(255, 255, 255, 0.7);
}
#search.active .search-input-container .material-icons {
#search.active .search-input-container .material-symbols {
color: inherit;
}
+76 -11
View File
@@ -1,15 +1,20 @@
<template>
<div id="status-bar" :style="moveWithSidebar" :class="{ 'dark-mode-header': isDarkMode, 'active': showStatusBar }" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div class="status-content" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div v-if="selectedCount > 0" class="status-info">
<span class="button">{{ selectedCount }}</span>
<span>{{ selectedItemsText }}</span>
</div>
<div v-else class="status-info">
<span class="directory-info">{{ directoryInfoText }}</span>
<!-- Left side: selection/directory info and stats for the editor and markdown viewer -->
<div class="status-info">
<template v-if="isEditorOrMarkdownView">
<span>{{ editorStatsText }}</span>
</template>
<template v-else>
<span v-if="selectedCount > 0" class="button">{{ selectedCount }}</span>
<span v-if="selectedCount > 0">{{ selectedItemsText }}</span>
<span v-else>{{ directoryInfoText }}</span>
</template>
</div>
<!-- Right side: Slider to control the listing size or the font size in editor -->
<div class="status-controls">
<div v-if="showGallerySize" class="gallery-size-control">
<div v-if="currentView === 'listingView'" class="gallery-size-control">
<span class="size-label">{{ $t("general.size") }}</span>
<input
v-model="gallerySize"
@@ -22,6 +27,17 @@
@change="commitGallerySize"
/>
</div>
<div v-if="currentView === 'editor'" class="gallery-size-control">
<span class="size-label">{{ $t("general.size") }}</span>
<input
v-model="editorFontSize"
type="range"
min="10"
max="24"
step="1"
/>
<span class="size-label">{{ editorFontSize }}px</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
</div>
</div>
@@ -39,11 +55,17 @@ export default {
};
},
computed: {
currentView() {
return getters.currentView();
},
isEditorOrMarkdownView() {
return this.currentView === 'editor' || this.currentView === 'markdownViewer';
},
showStatusBar() {
if (getters.isShare() && state.shareInfo.shareType === "upload") {
return false;
}
return getters.currentView() === "listingView";
return this.currentView === "listingView" || this.isEditorOrMarkdownView;
},
isDarkMode() {
return getters.isDarkMode();
@@ -57,9 +79,6 @@ export default {
numFiles() {
return getters.reqNumFiles();
},
showGallerySize() {
return getters.isCardView() && state.req?.items?.length > 0;
},
totalDirectorySize() {
if (!Array.isArray(state.req?.items)) return 0;
return state.req.items.reduce((total, item) => total + (item.size || 0), 0);
@@ -117,6 +136,30 @@ export default {
}
return {};
},
editorStatsText() {
const { lines, words, chars } = state.editorStats;
return [
[words, chars]
.map((v, i) => v != null && this.$t(i === 0 ? 'editor.words' : 'editor.chars', { count: v }))
.filter(Boolean)
.join(', '),
lines != null && this.$t('editor.lines', { count: lines })
].filter(Boolean).join(' | ');
},
editorFontSize: {
get() {
return state.editorFontSize;
},
set(value) {
mutations.setEditorFontSize(value);
}
},
},
mounted() {
window.addEventListener('wheel', this.handleWheel, { passive: false });
},
beforeUnmount() {
window.removeEventListener('wheel', this.handleWheel);
},
methods: {
updateGallerySize(event) {
@@ -157,6 +200,28 @@ export default {
mutations.updateCurrentUser({ viewMode: newMode });
}
},
// Ctrl + Mouse Wheel to adjust the slider sizes
handleWheel(event) {
if (!(event.ctrlKey || event.metaKey)) return;
const delta = event.deltaY > 0 ? 1 : -1; // Scroll down increases, up decreases
if (this.currentView === 'listingView') {
event.preventDefault();
let newSize = Math.min(9, Math.max(1, this.gallerySize - delta));
if (newSize !== this.gallerySize) {
this.gallerySize = newSize;
mutations.setGallerySize(newSize);
this.adjustViewMode();
}
} else if (this.currentView === 'editor') {
event.preventDefault();
let newSize = Math.min(24, Math.max(10, this.editorFontSize - delta));
if (newSize !== this.editorFontSize) {
this.editorFontSize = newSize;
}
}
}
},
};
</script>
+1 -1
View File
@@ -12,7 +12,7 @@
</template>
<script>
import { getIconClass } from "@/utils/material-icons";
import { getIconClass } from "@/utils/material-symbols";
export default {
name: "Toast",
+33 -56
View File
@@ -8,7 +8,7 @@
@dragleave.prevent="dragLeave($event, homeLink)"
@dragover.prevent="dragOver($event, homeLink)"
@drop.prevent="drop($event, homeLink)">
<i class="material-icons">home</i>
<i class="material-symbols">home</i>
</router-link>
</li>
<li class="item" v-for="(link, index) in items" :key="index">
@@ -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);
}
};
@@ -324,6 +325,8 @@ export default {
<style scoped>
#breadcrumbs {
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Hide scrollbar for Chrome, Safari and Opera */
@@ -331,12 +334,6 @@ export default {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
#breadcrumbs {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
#breadcrumbs * {
box-sizing: unset;
}
@@ -350,15 +347,16 @@ export default {
#breadcrumbs ul li {
display: inline-block;
margin: 0 8px 0 0;
position: relative;
}
#breadcrumbs ul li a {
display: flex;
height: 0.85em;
background: color-mix(in srgb, var(--alt-background) 90%, transparent);
background: var(--surfacePrimary);
text-align: center;
padding: 0.85em;
padding-right: 1.6em;
padding-left: 1.7em;
position: relative;
text-decoration: none;
@@ -370,6 +368,15 @@ export default {
user-select: none;
white-space: nowrap;
max-width: 90vw;
clip-path: polygon(
0% 0%,
calc(100% - 1.275em) 0%,
100% 50%,
calc(100% - 1.275em) 100%,
0% 100%,
1.275em 50%);
margin-right: -0.85em;
border: 1px solid rgba(0, 0, 0, 0.1);
}
#breadcrumbs ul li a .breadcrumb-text {
@@ -379,51 +386,29 @@ export default {
min-width: 0;
}
#breadcrumbs ul li a::after {
content: "";
border-top: 1.275em solid transparent;
border-bottom: 1.275em solid transparent;
border-left: 1.275em solid var(--alt-background);
border-left-color: color-mix(in srgb, var(--alt-background) 90%, transparent);
position: absolute;
right: -1.25em;
top: 0;
z-index: 5;
transition: all 0.2s ease;
}
#breadcrumbs ul li a::before {
content: "";
border-top: 1.275em solid transparent;
border-bottom: 1.275em solid transparent;
border-left: 1.275em solid var(--background);
border-left-color: color-mix(in srgb, var(--background) 85%, transparent);
position: absolute;
left: 0;
top: 0;
z-index: 1;
transition: all 0.2s ease;
}
#breadcrumbs ul li:first-child a {
border-top-left-radius: 0.85em;
border-bottom-left-radius: 0.85em;
padding-left: 1.275em;
z-index: 3;
}
#breadcrumbs ul li:first-child a::before {
display: none;
clip-path: polygon(
0% 0%,
calc(100% - 1.275em) 0%,
100% 50%,
calc(100% - 1.275em) 100%,
0% 100%);
padding-left: 1.1em;
}
#breadcrumbs ul li:last-child a {
padding-right: 1.275em;
border-top-right-radius: 0.85em;
border-bottom-right-radius: 0.85em;
}
#breadcrumbs ul li:last-child a::after {
display: none;
clip-path: polygon(
0% 0%,
100% 0%,
100% 100%,
0% 100%,
1.275em 50%);
margin-right: 0;
}
#breadcrumbs ul li a:hover {
@@ -431,10 +416,6 @@ export default {
color: white;
}
#breadcrumbs ul li a:hover::after {
border-left-color: var(--primaryColor);
}
#breadcrumbs ul li:last-child a.changeAvailable {
filter: contrast(0.8) hue-rotate(200deg) saturate(1);
}
@@ -445,10 +426,6 @@ export default {
z-index: 2;
}
.drag-over::after {
border-left-color: var(--primaryColor) !important;
}
@keyframes breadcrumbPulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
+381 -104
View File
@@ -1,5 +1,5 @@
<template>
<div class="image-ex-container" ref="container" @touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" @dblclick="zoomAuto"
<div class="image-ex-container" ref="container" @touchstart="touchStart" @touchmove.prevent="touchMove" @touchend="touchEnd" @dblclick="zoomAuto"
@mousedown="mousedownStart" @mousemove="mouseMove" @mouseup="mouseUp" @wheel="wheelMove">
<!-- Thumbnail placeholder (shown while full image loads, only if cached thumbnail exists) -->
<img
@@ -35,7 +35,7 @@
</template>
<script>
import { state, mutations, getters } from "@/store";
import { state, mutations, getters } from '@/store';
import throttle from "@/utils/throttle";
import { notify } from "@/notify";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
@@ -76,18 +76,25 @@ export default {
maxScale: 4,
minScale: 1,
isTiff: false,
// Swipe navigation properties
swipeStartTime: null,
swipeStartX: 0,
swipeStartY: 0,
swipeCurrentX: 0,
swipeCurrentY: 0,
isSwipeGesture: false,
hasStartedSwipe: false,
// Full-frame edge gestures (touch + mouse), zoom scale === 1 only
edgeKind: null, // null | 'horizontal' | 'vertical-dismiss'
edgeStartX: 0,
edgeStartY: 0,
edgeDx: 0,
edgeDy: 0,
dragOffsetX: 0,
dragOffsetY: 0,
gestureDecided: false,
swipeMinDistance: 150,
swipeMaxTime: 500,
swipeMaxVerticalDistance: 50,
gestureSnapBack: false,
edgeMouseActive: false,
showNavHint: false,
navHintDir: 'next', // 'prev' | 'next' — chevrons match nextPrevious.vue
showDismissHint: false,
dismissFlashActive: false,
edgeHintPx: 44,
edgeCommitX: 130,
edgeCommitY: 110,
edgeRubberMax: 100,
};
},
computed: {
@@ -119,6 +126,38 @@ export default {
const source = getters.isShare() ? state.shareInfo?.hash : this.source;
return getBestCachedImage(source, this.path, state.req?.modified);
},
navigationGestureAllowed() {
return state.navigation.enabled && getters.currentPrompt() == null;
},
hasImagePrevious() {
return this.navigationGestureAllowed && state.navigation.previousLink !== '';
},
hasImageNext() {
return this.navigationGestureAllowed && state.navigation.nextLink !== '';
},
navPrevCommitReady() {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
return (
this.hasImagePrevious &&
this.edgeDx >= this.edgeCommitX &&
ax >= ay
);
},
navNextCommitReady() {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
return (
this.hasImageNext &&
this.edgeDx <= -this.edgeCommitX &&
ax >= ay
);
},
dismissCommitReady() {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
return this.edgeDy >= this.edgeCommitY && ay >= ax;
},
},
mounted() {
this.isTiff = this.checkIfTiff(this.src);
@@ -152,10 +191,32 @@ export default {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
this.teardownEdgeMouseListeners();
mutations.setNavigationGestureHint({});
window.removeEventListener("resize", this.onResize);
document.removeEventListener("mouseup", this.onMouseUp);
},
methods: {
syncNavigationGestureHintToStore() {
let kind = null;
let commitReady = false;
let flashClose = false;
if (this.dismissFlashActive) {
kind = 'close';
commitReady = this.dismissCommitReady;
flashClose = true;
} else if (this.showDismissHint) {
kind = 'close';
commitReady = this.dismissCommitReady;
} else if (this.showNavHint && this.navHintDir === 'prev' && this.hasImagePrevious) {
kind = 'previous';
commitReady = this.navPrevCommitReady;
} else if (this.showNavHint && this.navHintDir === 'next' && this.hasImageNext) {
kind = 'next';
commitReady = this.navNextCommitReady;
}
mutations.setNavigationGestureHint({ kind, commitReady, flashClose });
},
loadFullImage() {
if (!this.src) return;
mutations.setLoading("preview-img", true);
@@ -179,8 +240,11 @@ export default {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
this.setCenter();
mutations.setLoading("preview-img", false);
// fullImageLoaded flips display from none → block via :style. Vue hasn't
// updated the DOM yet, so setCenter() in the same tick sees clientWidth/Height 0.
// Defer until after layout so centering / transform apply correctly.
this.scheduleSetCenter();
},
onImageError(event) {
const img = event.target;
@@ -201,6 +265,7 @@ export default {
if (img) {
img.style.display = 'block';
}
this.scheduleSetCenter();
},
checkIfTiff(src) {
const sufs = ["tif", "tiff", "dng", "cr2", "nef"];
@@ -226,44 +291,295 @@ export default {
onMouseUp() {
this.inDrag = false;
},
scheduleSetCenter() {
this.$nextTick(() => {
requestAnimationFrame(() => {
this.setCenter();
const img = this.$refs.imgex;
const container = this.$refs.container;
if (
img &&
this.fullImageLoaded &&
(!img.clientWidth || !img.clientHeight)
) {
requestAnimationFrame(() => this.setCenter());
}
// Force layout + compositor to pick up the decoded bitmap. Without this,
// WebKit/Blink sometimes leave the promoted layer black until display/position
// is toggled in DevTools or the window is resized.
requestAnimationFrame(() => {
if (!img || !this.fullImageLoaded) {
return;
}
void container?.offsetHeight;
void img.offsetHeight;
const prevOp = img.style.opacity;
img.style.opacity = '0.9999';
requestAnimationFrame(() => {
img.style.opacity = prevOp;
});
});
});
});
},
onResize: throttle(function () {
if (this.imageLoaded) {
this.setCenter();
this.doMove(this.position.relative.x, this.position.relative.y);
}
}, 100),
rubberband(value, max) {
const sign = value < 0 ? -1 : 1;
const a = Math.abs(value);
if (a <= max) {
return value;
}
return sign * (max + (a - max) * 0.32);
},
applyImgTransform() {
const img = this.$refs.imgex;
if (!img) {
return;
}
const transition = this.gestureSnapBack
? 'transform 0.22s cubic-bezier(0.32, 0.72, 0, 1)'
: 'none';
img.style.transition = transition;
if (this.scale === 1) {
img.style.transform = `translate3d(calc(-50% + ${this.dragOffsetX}px), calc(-50% + ${this.dragOffsetY}px), 0) scale(1)`;
} else {
const { x: rx, y: ry } = this.position.relative;
img.style.transform = `translate3d(calc(-50% + ${rx}px), calc(-50% + ${ry}px), 0) scale(${this.scale})`;
}
},
decideEdgeKind() {
if (this.edgeKind) {
return;
}
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
if (ax < 12 && ay < 12) {
return;
}
if (this.edgeDy > ax * 1.12 && this.edgeDy > 14) {
this.edgeKind = 'vertical-dismiss';
this.gestureDecided = true;
} else if (ax > ay * 1.12 && ax > 14) {
this.edgeKind = 'horizontal';
this.gestureDecided = true;
}
},
applyEdgeVisuals() {
if (!this.edgeKind) {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
if (ax <= 8 && ay <= 8) {
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.showNavHint = false;
this.showDismissHint = false;
this.applyImgTransform();
this.syncNavigationGestureHintToStore();
return;
}
if (ax > ay) {
this.dragOffsetX = this.rubberband(this.edgeDx, this.edgeRubberMax);
this.dragOffsetY = 0;
this.showNavHint = ax >= this.edgeHintPx;
this.navHintDir = this.edgeDx > 0 ? 'prev' : 'next';
if (this.navHintDir === 'prev' && !this.hasImagePrevious) {
this.showNavHint = false;
}
if (this.navHintDir === 'next' && !this.hasImageNext) {
this.showNavHint = false;
}
this.showDismissHint = false;
} else {
this.dragOffsetX = 0;
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
this.showNavHint = false;
}
this.applyImgTransform();
this.syncNavigationGestureHintToStore();
return;
}
if (this.edgeKind === 'horizontal') {
this.dragOffsetX = this.rubberband(this.edgeDx, this.edgeRubberMax);
this.dragOffsetY = 0;
const adx = Math.abs(this.edgeDx);
this.showNavHint = adx >= this.edgeHintPx;
this.navHintDir = this.edgeDx > 0 ? 'prev' : 'next';
if (this.navHintDir === 'prev' && !this.hasImagePrevious) {
this.showNavHint = false;
}
if (this.navHintDir === 'next' && !this.hasImageNext) {
this.showNavHint = false;
}
this.showDismissHint = false;
} else {
this.dragOffsetX = 0;
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
this.showNavHint = false;
}
this.applyImgTransform();
this.syncNavigationGestureHintToStore();
},
snapBackEdgeGesture() {
this.gestureSnapBack = true;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.showNavHint = false;
this.showDismissHint = false;
this.edgeKind = null;
this.gestureDecided = false;
this.edgeDx = 0;
this.edgeDy = 0;
this.applyImgTransform();
mutations.setNavigationGestureHint({});
setTimeout(() => {
this.gestureSnapBack = false;
this.applyImgTransform();
}, 240);
},
resetEdgeGestureImmediate() {
this.edgeKind = null;
this.gestureDecided = false;
this.edgeDx = 0;
this.edgeDy = 0;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.showNavHint = false;
this.showDismissHint = false;
this.gestureSnapBack = false;
this.dismissFlashActive = false;
mutations.setNavigationGestureHint({});
},
finishEdgeGesture() {
if (this.scale !== 1) {
this.resetEdgeGestureImmediate();
this.applyImgTransform();
return;
}
let kind = this.edgeKind;
if (!kind) {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
if (ax < this.edgeHintPx && ay < this.edgeHintPx) {
this.snapBackEdgeGesture();
return;
}
kind = ax >= ay ? 'horizontal' : 'vertical-dismiss';
}
if (kind === 'horizontal') {
if (this.edgeDx >= this.edgeCommitX && this.hasImagePrevious) {
this.$emit('navigate-previous');
this.resetEdgeGestureImmediate();
this.applyImgTransform();
return;
}
if (this.edgeDx <= -this.edgeCommitX && this.hasImageNext) {
this.$emit('navigate-next');
this.resetEdgeGestureImmediate();
this.applyImgTransform();
return;
}
} else if (kind === 'vertical-dismiss') {
if (this.edgeDy >= this.edgeCommitY) {
this.dismissFlashActive = true;
this.showDismissHint = true;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.edgeKind = null;
this.gestureDecided = false;
this.applyImgTransform();
this.syncNavigationGestureHintToStore();
setTimeout(() => {
this.$emit('close-preview');
}, 120);
setTimeout(() => {
this.dismissFlashActive = false;
this.showDismissHint = false;
mutations.setNavigationGestureHint({});
}, 420);
return;
}
}
this.snapBackEdgeGesture();
},
teardownEdgeMouseListeners() {
document.removeEventListener('mousemove', this.onEdgeMouseMove, true);
document.removeEventListener('mouseup', this.onEdgeMouseUp, true);
this.edgeMouseActive = false;
},
onEdgeMouseMove(event) {
if (!this.edgeMouseActive || this.scale !== 1) {
return;
}
this.edgeDx = event.clientX - this.edgeStartX;
this.edgeDy = event.clientY - this.edgeStartY;
this.decideEdgeKind();
this.applyEdgeVisuals();
event.preventDefault();
},
onEdgeMouseUp(event) {
if (!this.edgeMouseActive) {
return;
}
this.teardownEdgeMouseListeners();
this.edgeDx = event.clientX - this.edgeStartX;
this.edgeDy = event.clientY - this.edgeStartY;
this.finishEdgeGesture();
event.preventDefault();
},
setCenter() {
const container = this.$refs.container;
const img = this.$refs.imgex;
if (!container || !img || !img.clientWidth || !img.clientHeight) {
return;
}
// Images are centered using CSS (top: 50%, left: 50%, transform: translate(-50%, -50%))
// Reset pan position when centering
this.position.relative = { x: 0, y: 0 };
this.position.center.x = Math.floor((container.clientWidth - img.clientWidth) / 2);
this.position.center.y = Math.floor((container.clientHeight - img.clientHeight) / 2);
// Update transform to reflect centered state
if (this.scale === 1) {
img.style.transform = 'translate(-50%, -50%) scale(1)';
} else {
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
this.resetEdgeGestureImmediate();
this.applyImgTransform();
},
mousedownStart(event) {
if (event.button !== 0) return;
if (this.scale === 1) {
this.teardownEdgeMouseListeners();
this.edgeMouseActive = true;
this.edgeStartX = event.clientX;
this.edgeStartY = event.clientY;
this.edgeDx = 0;
this.edgeDy = 0;
this.edgeKind = null;
this.gestureDecided = false;
document.addEventListener('mousemove', this.onEdgeMouseMove, true);
document.addEventListener('mouseup', this.onEdgeMouseUp, true);
event.preventDefault();
return;
}
this.lastX = null;
this.lastY = null;
this.inDrag = true;
event.preventDefault();
},
mouseMove(event) {
if (!this.inDrag) return;
this.doMove(event.movementX, event.movementY);
event.preventDefault();
if (event.button !== 0) return;
if (this.scale > 1 && this.inDrag) {
this.doMove(event.movementX, event.movementY);
event.preventDefault();
}
},
mouseUp(event) {
this.inDrag = false;
event.preventDefault();
if (event.button !== 0) return;
if (this.scale > 1) {
this.inDrag = false;
event.preventDefault();
}
},
touchStart(event) {
this.lastX = null;
@@ -271,16 +587,15 @@ export default {
this.lastTouchDistance = null;
if (event.targetTouches.length === 1 && this.scale === 1) {
const touch = event.targetTouches[0];
this.swipeStartTime = Date.now();
this.swipeStartX = touch.pageX;
this.swipeStartY = touch.pageY;
this.swipeCurrentX = touch.pageX;
this.swipeCurrentY = touch.pageY;
this.isSwipeGesture = false;
this.hasStartedSwipe = false;
this.edgeStartX = touch.pageX;
this.edgeStartY = touch.pageY;
this.edgeDx = 0;
this.edgeDy = 0;
this.edgeKind = null;
this.gestureDecided = false;
} else {
this.resetSwipeTracking();
this.resetEdgeGestureImmediate();
this.applyImgTransform();
}
if (event.targetTouches.length < 2) {
setTimeout(() => {
@@ -314,30 +629,15 @@ export default {
event.preventDefault();
},
touchMove(event) {
// Default is prevented via @touchmove.prevent so the browser cannot steal
// vertical drags (pull-to-refresh) before our edge-gesture thresholds.
if (event.targetTouches.length === 1 && this.scale === 1) {
const touch = event.targetTouches[0];
this.swipeCurrentX = touch.pageX;
this.swipeCurrentY = touch.pageY;
if (!this.gestureDecided) {
const deltaX = Math.abs(this.swipeCurrentX - this.swipeStartX);
const deltaY = Math.abs(this.swipeCurrentY - this.swipeStartY);
if (deltaX > 10 || deltaY > 10) {
this.gestureDecided = true;
if (deltaX > deltaY * 2) {
this.isSwipeGesture = true;
event.preventDefault();
} else {
this.isSwipeGesture = false;
}
}
}
if (this.gestureDecided && this.isSwipeGesture) {
event.preventDefault();
return;
}
}
if (this.scale > 1 || event.targetTouches.length >= 2) {
event.preventDefault();
this.edgeDx = touch.pageX - this.edgeStartX;
this.edgeDy = touch.pageY - this.edgeStartY;
this.decideEdgeKind();
this.applyEdgeVisuals();
return;
}
if (this.lastX === null) {
this.lastX = event.targetTouches[0].pageX;
@@ -377,11 +677,7 @@ export default {
doMove(x, y) {
this.position.relative.x += x;
this.position.relative.y += y;
const img = this.$refs.imgex;
if (img) {
// Combine centering (-50%) with pan offset and scale
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
this.applyImgTransform();
},
wheelMove(event) {
event.preventDefault()
@@ -392,52 +688,23 @@ export default {
this.scale = Math.max(this.minScale, Math.min(this.maxScale, this.scale));
if (this.scale === 1) {
this.position.relative = { x: 0, y: 0 };
this.resetEdgeGestureImmediate();
}
const img = this.$refs.imgex;
if (img) {
// Combine centering (-50%) with pan offset and scale
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
this.applyImgTransform();
},
pxStringToNumber(style) {
return +style.replace("px", "");
},
touchEnd(event) {
let handledSwipe = false;
if (this.isSwipeGesture && this.swipeStartTime && this.scale === 1) {
const swipeEndTime = Date.now();
const swipeDuration = swipeEndTime - this.swipeStartTime;
const deltaX = this.swipeCurrentX - this.swipeStartX;
const deltaY = Math.abs(this.swipeCurrentY - this.swipeStartY);
const absDelataX = Math.abs(deltaX);
if (
swipeDuration <= this.swipeMaxTime &&
absDelataX >= this.swipeMinDistance &&
deltaY <= this.swipeMaxVerticalDistance
) {
if (deltaX > 0) {
this.$emit('navigate-previous');
} else {
this.$emit('navigate-next');
}
handledSwipe = true;
event.preventDefault();
}
}
if (!handledSwipe && this.scale > 1) {
if (this.scale === 1 && event.changedTouches.length > 0) {
const t = event.changedTouches[0];
this.edgeDx = t.pageX - this.edgeStartX;
this.edgeDy = t.pageY - this.edgeStartY;
this.finishEdgeGesture();
event.preventDefault();
} else if (this.scale > 1) {
event.preventDefault();
}
this.resetSwipeTracking();
},
resetSwipeTracking() {
this.swipeStartTime = null;
this.swipeStartX = 0;
this.swipeStartY = 0;
this.swipeCurrentX = 0;
this.swipeCurrentY = 0;
this.isSwipeGesture = false;
this.hasStartedSwipe = false;
this.gestureDecided = false;
},
},
watch: {
@@ -471,7 +738,9 @@ export default {
if (!this.fullImageLoaded && !this.imageLoaded) {
// Show the image even if load event didn't fire (might be partially loaded)
this.fullImageLoaded = true;
this.imageLoaded = true;
mutations.setLoading("preview-img", false);
this.scheduleSetCenter();
}
}, 30000); // 30 second timeout
});
@@ -479,7 +748,8 @@ export default {
this.scale = 1;
this.position.relative = { x: 0, y: 0 };
this.resetSwipeTracking();
this.resetEdgeGestureImmediate();
this.applyImgTransform();
},
},
};
@@ -487,12 +757,18 @@ export default {
<style>
.image-ex-container {
width: 100%;
height: 100%;
min-height: 0;
max-width: 100%;
max-height: 100%;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
/* Block browser pull-to-refresh / overscroll so vertical dismiss & edge gestures win */
overscroll-behavior: none;
touch-action: none;
}
.image-ex-img {
@@ -501,7 +777,8 @@ export default {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* translate3d tends to composite more reliably than translate with decoded <img> bitmaps */
transform: translate3d(-50%, -50%, 0);
object-fit: contain;
}
@@ -509,7 +786,7 @@ export default {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transform: translate3d(-50%, -50%, 0);
z-index: 10;
display: flex;
align-items: center;
+10 -1
View File
@@ -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,
+4 -4
View File
@@ -6,7 +6,7 @@
</div>
<!-- Error state -->
<div v-if="isRootInstance && error" class="tree-error">
<i class="material-icons">error</i>
<i class="material-symbols">error</i>
<span>{{ $t("prompts.error") }}</span>
</div>
<ul v-if="effectiveNodes && effectiveNodes.length" class="tree-list">
@@ -31,7 +31,7 @@
class="expand-icon"
@click.stop="toggleExpand(node, true)"
>
<i class="material-icons">
<i class="material-symbols">
{{ node.expanded ? 'expand_more' : 'chevron_right' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</i>
</span>
@@ -55,7 +55,7 @@
:currentPath="currentPath"
/>
<div v-else-if="node.expanded && node.children && node.children.length === 0" class="tree-empty-folder">
<i class="material-icons">sentiment_dissatisfied</i>
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
<span>{{ $t('files.lonely') }}</span>
</div>
<div v-else-if="node.expanded && node.loading" class="tree-loading">
@@ -68,7 +68,7 @@
</ul>
<!-- Empty state -->
<div v-if="isRootInstance && effectiveNodes && effectiveNodes.length === 0 && !loading && !error" class="tree-empty">
<i class="material-icons">sentiment_dissatisfied</i>
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
{{ $t('files.lonely') }}
</div>
</div>
+69 -59
View File
@@ -2,9 +2,9 @@
<!-- Unified preview container for all types -->
<span v-if="hasPreviewImage || shouldUse3DPreview" class="image-preview" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
<!-- Overlay icons (folder/animation) positioned top-left -->
<i v-if="hasPreviewImage && hasMotion && isFile" class="material-icons overlay-icon muted">animation</i>
<i v-else-if="hasPreviewImage && !isFile" class="material-icons overlay-icon muted">folder</i>
<i v-if="isShared" class="material-icons overlay-icon">group</i>
<i v-if="hasPreviewImage && hasMotion && isFile" class="material-symbols-outlined overlay-icon">animation</i>
<i v-else-if="hasPreviewImage && !isFile" class="material-symbols overlay-icon">folder</i>
<i v-if="isShared" class="material-symbols overlay-icon">group</i>
<!-- Preview content: image, 3D, or fallback -->
<img v-if="hasPreviewImage" :key="imageTargetSrc" :src="imageDisplaySrc" ref="thumbnail" />
<ThreeJs v-else-if="shouldUse3DPreview && !threeJsError"
@@ -23,8 +23,8 @@
<!-- Regular material icon (no preview) -->
<span v-else class="image-preview">
<i :class="[classes, { active: active, clickable: clickable }]"> {{ materialIcon }} </i>
<i v-if="isShared" class="material-icons overlay-icon">group</i>
<i :class="[classes, { active: active, clickable: clickable }]"> {{ materialSymbol }} </i>
<i v-if="isShared" class="material-symbols overlay-icon">group</i>
</span>
</template>
@@ -95,7 +95,7 @@ export default {
},
data() {
return {
materialIcon: "",
materialSymbol: "",
classes: "",
svgPath: "",
previewTimeouts: [],
@@ -213,9 +213,6 @@ export default {
this.hasPreview
);
},
isMaterialIcon() {
return this.materialIcon !== "";
},
shouldUse3DPreview() {
// Check if we should use 3D preview instead of regular icon
if (!this.isFile || !this.size || !this.path) return false;
@@ -424,10 +421,10 @@ export default {
},
mounted() {
const result = this.getIconForType();
this.classes = result.classes || "material-icons";
this.classes = result.classes || "material-symbols";
// @ts-ignore
this.color = result.color || "lightgray";
this.materialIcon = result.materialIcon || "";
this.materialSymbol = result.materialSymbol || "";
// @ts-ignore
this.svgPath = result.svgPath || "";
this.updateImageTargetSrc();
@@ -453,9 +450,6 @@ export default {
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
z-index: 2;
color: white;
}
.overlay-icon.muted {
opacity: 0.7;
}
@@ -474,13 +468,11 @@ export default {
.icon {
font-size: 1.5rem;
/* Default size */
fill: currentColor;
/* Uses inherited color */
border-radius: 0.2em;
padding: 0.1em;
background: var(--iconBackground);
/* Performance optimization */
will-change: auto;
transform: translateZ(0);
}
@@ -489,16 +481,9 @@ export default {
background: var(--background);
}
.purple-icons {
color: purple;
}
/* Icon Colors */
.blue-icons {
color: var(--icon-blue);
}
/* Icon Colors */
/* -----------
Icon Colors
----------- */
.primary-icons {
color: var(--primaryColor);
}
@@ -507,11 +492,55 @@ export default {
text-shadow: 0px 0px 1px #000;
}
/* blue variations */
.blue-icons {
color: var(--icon-blue);
}
.deep-blue-icons {
color: rgb(29, 95, 191);
}
.lightblue-icons {
color: lightskyblue;
}
.skyblue-icons {
color: rgb(42, 170, 242);
}
/* purple variations */
.purple-icons {
color: purple;
}
.plum-icons {
color: plum;
}
/* yellow */
.yellow-icons {
color: yellow;
}
/* orange/red variations */
.orange-icons {
color: orange;
}
.red-icons {
color: rgb(211, 16, 16);
}
.deep-orange-icons {
color: rgb(255, 111, 0);
}
.brown-icons {
color: brown;
}
.coral-icons {
color: lightcoral;
}
@@ -519,53 +548,31 @@ export default {
color: tan;
}
.plum-icons {
color: plum;
}
.red-icons {
color: rgb(246, 70, 70);
}
.white-icons {
color: white;
}
.brown-icons {
color: brown;
}
#listingView.gallery .listing-item i.white-icons,
.active.white-icons {
color: var(--activeWhiteIcon);
}
.deep-blue-icons {
color: rgb(29, 95, 191);
}
/* green variations */
.green-icons {
color: rgb(23, 128, 74);
}
.red-orange-icons {
color: rgb(255, 147, 111);
.light-green-icons {
color: rgb(48, 207, 117);
}
/* white variations */
.white-icons {
color: white;
}
.gray-icons {
color: gray;
}
.skyblue-icons {
color: rgb(42, 170, 242);
}
.lightgray-icons {
color: rgb(176, 176, 176);
}
.yellow-icons {
color: yellow;
#listingView.gallery .listing-item i.white-icons,
.active.white-icons {
color: var(--activeWhiteIcon);
}
/* Unified .image-preview container - works universally, always square */
@@ -594,6 +601,9 @@ export default {
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
/* 3D viewers - universal */
@@ -10,7 +10,7 @@
:aria-label="$t('files.sortByName')"
>
<span>{{ $t("general.name") }}</span>
<i v-if="nameSorted" class="material-icons">{{ nameIcon }}</i>
<i v-if="nameSorted" class="material-symbols">{{ nameIcon }}</i>
</p>
<p
@@ -22,8 +22,8 @@
:title="$t('files.sortBySize')"
:aria-label="$t('files.sortBySize')"
>
<i v-if="sizeSorted" class="material-symbols">{{ sizeIcon }}</i>
<span>{{ $t("general.size") }}</span>
<i v-if="sizeSorted" class="material-icons">{{ sizeIcon }}</i>
</p>
<p
@@ -35,8 +35,8 @@
:title="$t('files.sortByLastModified')"
:aria-label="$t('files.sortByLastModified')"
>
<i v-if="modifiedSorted" class="material-symbols">{{ modifiedIcon }}</i>
<span>{{ $t("files.lastModified") }}</span>
<i v-if="modifiedSorted" class="material-icons">{{ modifiedIcon }}</i>
</p>
<p
@@ -49,8 +49,8 @@
:title="$t('files.sortByDuration')"
:aria-label="$t('files.sortByDuration')"
>
<i v-if="durationSorted" class="material-symbols">{{ durationIcon }}</i>
<span>{{ $t("files.duration") }}</span>
<i v-if="durationSorted" class="material-icons">{{ durationIcon }}</i>
</p>
<span v-if="quickDownloadEnabled" class="placeholder"></span>
</div>
@@ -188,18 +188,23 @@ span {
flex: 1;
}
.desktop-view .size,
.desktop-view .modified,
.desktop-view .duration {
.desktop-view .modified{
min-width: 15%;
flex: 0;
}
.desktop-view .size,
.desktop-view .duration {
min-width: 10%;
flex: 0;
}
.size,
.modified,
.duration {
flex: 1;
padding-left: 1em;
justify-content: flex-end;
text-align: end;
}
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff. Show more