mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 21:22:00 -05:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19d60a8a24 | ||
|
|
d583359f4e | ||
|
|
2c0f9f26d6 | ||
|
|
efe10ec6d5 | ||
|
|
0f37b8120f | ||
|
|
c55107e31b | ||
|
|
ee0307d80a | ||
|
|
e64179d6d2 | ||
|
|
68f0f0204e | ||
|
|
9e18c99b05 | ||
|
|
cdbc7a35f7 | ||
|
|
a18f268547 | ||
|
|
28edf9ce0e | ||
|
|
e99b22349d | ||
|
|
9775cc03fd | ||
|
|
18333e21fd | ||
|
|
ad3d184811 | ||
|
|
d47c2d9c0d | ||
|
|
04e4324981 | ||
|
|
44edfcafa1 | ||
|
|
be2e78f729 | ||
|
|
c22fc59035 | ||
|
|
ad82de28e8 | ||
|
|
0481052308 | ||
|
|
87d241d166 | ||
|
|
092c97f9ae |
No files matched your search
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,13 +56,4 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
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
|
||||
run: npm i && npm run i18n:check
|
||||
@@ -1,6 +1,4 @@
|
||||
*.db
|
||||
*.db.old
|
||||
*.sqlite
|
||||
*.bak
|
||||
*.log
|
||||
*.mjs
|
||||
|
||||
+1
-107
@@ -2,113 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v1.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**:
|
||||
@@ -139,6 +32,7 @@ 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**:
|
||||
|
||||
+2
-1
@@ -4,12 +4,13 @@ ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev
|
||||
RUN apk update && apk add --no-cache gcc musl-dev upx
|
||||
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
|
||||
|
||||
@@ -18,6 +18,5 @@ COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser set -u testuser1,testuser1
|
||||
RUN ./filebrowser set rule -s access -p / -r user -v admin -allow -c config.yaml
|
||||
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
|
||||
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
|
||||
@@ -3,11 +3,12 @@ ARG VERSION
|
||||
ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add
|
||||
RUN apk update && apk add upx
|
||||
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
|
||||
|
||||
@@ -15,4 +15,3 @@ 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: 1,
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
@@ -53,6 +53,8 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
name: "light-screenshots",
|
||||
@@ -60,6 +62,8 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 5000,
|
||||
timeout: 3000,
|
||||
testDir: "./tests/playwright/settings",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -12,7 +11,6 @@ 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"
|
||||
@@ -90,111 +88,6 @@ 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
|
||||
|
||||
@@ -28,36 +28,6 @@ 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
|
||||
@@ -69,8 +39,11 @@ 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)
|
||||
@@ -79,9 +52,54 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
}
|
||||
})
|
||||
|
||||
if opts.Metadata && metadataCount > 0 {
|
||||
addMetadataToChildren(response, opts, &wg)
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -226,17 +244,18 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
|
||||
return response, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
|
||||
// otherwise response keeps unfiltered Folders/Files while CheckChildItemAccess only mutates info.
|
||||
// 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
|
||||
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]
|
||||
@@ -271,13 +290,9 @@ 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,
|
||||
}
|
||||
@@ -288,6 +303,7 @@ 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 {
|
||||
@@ -297,12 +313,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,
|
||||
}
|
||||
@@ -310,7 +326,9 @@ 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
|
||||
@@ -761,21 +779,22 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyDefaultFilePerm := false
|
||||
var stat os.FileInfo
|
||||
stat, err = os.Stat(realPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat file: %w", err)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
applyDefaultFilePerm = true
|
||||
} else if stat.IsDir() {
|
||||
return fmt.Errorf("%w: %q", errors.ErrIsDirectory, path)
|
||||
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
|
||||
}
|
||||
|
||||
// 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, then Chmod bypasses umask)
|
||||
// For existing regular files: do not Chmod so saves preserve mode and special bits
|
||||
// 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)
|
||||
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -788,11 +807,11 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
return 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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Refresh the file itself
|
||||
|
||||
@@ -108,6 +108,7 @@ 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",
|
||||
@@ -150,8 +151,7 @@ var types = map[string]string{
|
||||
".evy": "application/envoy",
|
||||
".exe": "application/x-msdownload",
|
||||
".dll": "application/x-msdownload",
|
||||
".bat": "application/x-msdos-program",
|
||||
".cmd": "application/x-msdos-program",
|
||||
".bat": "application/x-msdownload",
|
||||
".msi": "application/x-msdownload",
|
||||
".f77": "text/x-fortran",
|
||||
".f90": "text/x-fortran",
|
||||
@@ -228,6 +228,7 @@ var types = map[string]string{
|
||||
".js": ContentJavascriptHeaderValue,
|
||||
".mjs": ContentJavascriptHeaderValue,
|
||||
".json": ContentJSONHeaderValue,
|
||||
".vue": ContentJavascriptHeaderValue,
|
||||
".jut": "image/jutvision",
|
||||
".kar": "audio/midi",
|
||||
".karbon": "application/vnd.kde.karbon",
|
||||
@@ -259,6 +260,7 @@ 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",
|
||||
@@ -308,6 +310,7 @@ 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",
|
||||
@@ -343,14 +346,15 @@ 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",
|
||||
@@ -442,11 +446,9 @@ var types = map[string]string{
|
||||
".sprite": "application/x-sprite",
|
||||
".spx": "audio/ogg",
|
||||
".src": "application/x-wais-source",
|
||||
".srt": "text/subtitle-srt",
|
||||
".ass": "text/subtitle-ass",
|
||||
".vtt": "text/subtitle-vtt",
|
||||
".ssa": "text/subtitle-ssa",
|
||||
".srt": "text/plain",
|
||||
".sbv": "text/plain",
|
||||
".ssa": "text/plain",
|
||||
".ssi": "text/x-server-parsed-html",
|
||||
".ssm": "application/streamingmedia",
|
||||
".sst": "application/vndms-pkicertstore",
|
||||
@@ -601,52 +603,10 @@ 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",
|
||||
|
||||
@@ -19,7 +19,7 @@ func MakeUserDir(fullPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func MakeUserDirs(u *users.User, createDir bool) error {
|
||||
func MakeUserDirs(u *users.User, disableScopeChange 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,31 +27,48 @@ func MakeUserDirs(u *users.User, createDir bool) error {
|
||||
for i, scope := range u.Scopes {
|
||||
source, ok := settings.Config.Server.SourceMap[scope.Name]
|
||||
if !ok {
|
||||
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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
|
||||
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 {
|
||||
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
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
|
||||
}
|
||||
} else {
|
||||
if !Exists(fullPath) {
|
||||
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", fullPath)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
u.Scopes[i] = scope
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -12,45 +12,9 @@ import (
|
||||
var PermFile os.FileMode
|
||||
var PermDir os.FileMode
|
||||
|
||||
// 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
|
||||
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
|
||||
PermFile = PermFileOctal
|
||||
PermDir = PermDirOctal
|
||||
}
|
||||
|
||||
// MoveFile moves a file from src to dst.
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
package fileutils
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
import "testing"
|
||||
|
||||
func TestCommonPrefix(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
|
||||
@@ -15,8 +15,7 @@ import (
|
||||
|
||||
// JSONAuth is a json implementation of an Auther.
|
||||
type JSONAuth struct {
|
||||
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
|
||||
DisableOtp bool `json:"disableOtp" yaml:"disableOtp"`
|
||||
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
|
||||
}
|
||||
|
||||
// Auth authenticates the user via a json in content body.
|
||||
@@ -24,12 +23,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 {
|
||||
@@ -59,7 +58,8 @@ 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)
|
||||
}
|
||||
if user.TOTPSecret != "" && !auther.DisableOtp {
|
||||
// check for OTP for password
|
||||
if user.TOTPSecret != "" {
|
||||
if totpCode == "" {
|
||||
return nil, errors.ErrNoTotpProvided
|
||||
}
|
||||
|
||||
+7
-102
@@ -1,12 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
@@ -52,12 +47,14 @@ 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 jwtVerificationKey(secret, token.Method, algorithm)
|
||||
|
||||
// Return the secret key for HMAC algorithms
|
||||
// For RSA/ECDSA, this would need to be a public key
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -103,102 +100,10 @@ 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{}:
|
||||
@@ -224,6 +129,6 @@ func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -1,14 +1,6 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -152,140 +144,6 @@ func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ES256_PublicKeyPEM(t *testing.T) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
|
||||
username := "ec-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "ES256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_RS256_PublicKeyPEM(t *testing.T) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
|
||||
username := "rsa-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "RS256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ES256_WrongCurveRejected(t *testing.T) {
|
||||
p256Priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
p384Priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
verifyDER, err := x509.MarshalPKIXPublicKey(&p384Priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
verifyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: verifyDER}))
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "u",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(p256Priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, verifyPEM, "ES256", "sub")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PEM public key curve does not match algorithm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_CertificatePEM(t *testing.T) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "jwt-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
||||
|
||||
username := "cert-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, certPEM, "ES256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_ArrayOfStrings(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []string{"admin", "users"},
|
||||
|
||||
@@ -146,14 +146,14 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
|
||||
}
|
||||
|
||||
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
|
||||
// get data from cache
|
||||
cachedSecret, found := TotpCache.Get(user.Username)
|
||||
if !found && user.TOTPSecret == "" {
|
||||
return fmt.Errorf("OTP token not found in cache, please generate a new one")
|
||||
}
|
||||
useCache := found && user.TOTPSecret == ""
|
||||
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
|
||||
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
|
||||
if useCache {
|
||||
if found {
|
||||
splitSecret := strings.Split(cachedSecret, "||")
|
||||
if len(splitSecret) < 2 {
|
||||
return fmt.Errorf("invalid cached OTP token format")
|
||||
@@ -173,7 +173,9 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
}
|
||||
secretToValidate = decryptedSecret
|
||||
}
|
||||
// --- END: ADD THIS DECRYPTION LOGIC ---
|
||||
|
||||
// Validate the token using the (now plaintext) secret.
|
||||
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
|
||||
Period: TOTPPeriod,
|
||||
Skew: TOTPSkew,
|
||||
@@ -196,7 +198,6 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
logger.Error("error updating user with OTP token:", err)
|
||||
return fmt.Errorf("error updating user with OTP token: %w", err)
|
||||
}
|
||||
TotpCache.Delete(user.Username)
|
||||
} else {
|
||||
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
|
||||
}
|
||||
|
||||
+60
-70
@@ -23,7 +23,7 @@ var (
|
||||
)
|
||||
|
||||
// return bool to indicate if the program should continue running
|
||||
func runCLI() (bool, bool) {
|
||||
func runCLI() bool {
|
||||
|
||||
generateYaml()
|
||||
|
||||
@@ -53,100 +53,85 @@ func runCLI() (bool, bool) {
|
||||
// Show help if requested
|
||||
if help {
|
||||
generalUsage()
|
||||
return false, false
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a new FlagSet for the 'set' subcommand
|
||||
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
|
||||
var user, dbConfig string
|
||||
var asAdmin bool
|
||||
var sourceName, indexPath, ruleCategory, value string
|
||||
var fsPath, 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(&sourceName, "s", "", "source name to apply the rule to")
|
||||
setCmd.StringVar(&fsPath, "f", "", "Real filesystem path (e.g. /mnt/storage) for rule command")
|
||||
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)")
|
||||
|
||||
storeCfg := configPath
|
||||
cliSubcmd := ""
|
||||
var setKind string
|
||||
|
||||
// Parse subcommand flags only if a subcommand is specified
|
||||
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, 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
|
||||
`, version.Version, version.CommitSHA, version.Version)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
|
||||
@@ -163,6 +148,10 @@ 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 {
|
||||
@@ -213,6 +202,7 @@ 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{
|
||||
|
||||
+6
-5
@@ -54,14 +54,10 @@ commands:
|
||||
}
|
||||
|
||||
func StartFilebrowser() {
|
||||
keepGoing, dbExists := runCLI()
|
||||
keepGoing := 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 != "" {
|
||||
@@ -80,6 +76,11 @@ 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")
|
||||
|
||||
+4
-6
@@ -46,7 +46,8 @@ func validateUserInfo(newDB bool) {
|
||||
}
|
||||
adminUser := settings.Config.Auth.AdminUsername
|
||||
adminPass := settings.Config.Auth.AdminPassword
|
||||
if user.Username == adminUser && adminPass != "" && user.LoginMethod == users.LoginMethodPassword {
|
||||
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
|
||||
if user.Username == adminUser && adminPass != "" && passwordEnabled {
|
||||
logger.Info("Resetting admin user to default username and password.")
|
||||
user.Permissions.Admin = true
|
||||
user.Password = settings.Config.Auth.AdminPassword
|
||||
@@ -62,11 +63,7 @@ func validateUserInfo(newDB bool) {
|
||||
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
|
||||
}
|
||||
}
|
||||
fields := []string{"Scopes", "SidebarLinks", "Tokens", "Permissions", "Preview", "ShowFirstLogin", "LoginMethod", "Version"}
|
||||
if changePass {
|
||||
fields = append(fields, "Password")
|
||||
}
|
||||
err := store.Users.Update(user, true, fields...)
|
||||
err := store.Users.Save(user, changePass, true)
|
||||
if err != nil {
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
}
|
||||
@@ -97,6 +94,7 @@ func updateUserScopes(user *users.User) bool {
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: src.Path,
|
||||
Scope: existingScope.Scope,
|
||||
|
||||
@@ -156,6 +156,7 @@ 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}
|
||||
|
||||
@@ -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 configured")
|
||||
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
|
||||
ErrUnauthorized = errors.New("user unauthorized")
|
||||
ErrNotIndexed = errors.New("directory or item excluded from indexing")
|
||||
ErrNotViewable = errors.New("directory or item is not viewable")
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// TestApplyUserDefaults_copiesUserDefaultsOntoUser guards against drift between
|
||||
// [UserDefaults] and [ApplyUserDefaults] (regression for #2278-style bugs).
|
||||
func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
dark := false
|
||||
dlFalse := false
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{
|
||||
EditorQuickSave: true,
|
||||
HideSidebarFileActions: true,
|
||||
DisableQuickToggles: true,
|
||||
DisableSearchOptions: true,
|
||||
StickySidebar: false,
|
||||
HideFilesInTree: true,
|
||||
DarkMode: &dark,
|
||||
Locale: "de",
|
||||
ViewMode: "list",
|
||||
SingleClick: true,
|
||||
ShowHidden: true,
|
||||
DateFormat: true,
|
||||
GallerySize: 7,
|
||||
ThemeColor: "var(--red)",
|
||||
QuickDownload: true,
|
||||
DisablePreviewExt: ".exe",
|
||||
DisableViewingExt: ".bat",
|
||||
DisableSettings: true,
|
||||
LockPassword: true,
|
||||
DisableUpdateNotifications: true,
|
||||
DeleteWithoutConfirming: true,
|
||||
DeleteAfterArchive: false,
|
||||
DisableOfficePreviewExt: "legacy",
|
||||
DisableOnlyOfficeExt: ".x",
|
||||
CustomTheme: "Night",
|
||||
ShowSelectMultiple: true,
|
||||
DebugOffice: true,
|
||||
PreferEditorForMarkdown: true,
|
||||
LoginMethod: "password",
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 3,
|
||||
UploadChunkSize: 5,
|
||||
DownloadChunkSize: 2,
|
||||
},
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Api: true,
|
||||
Admin: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Realtime: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Download: &dlFalse,
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
DisableHideSidebar: true,
|
||||
Image: boolPtr(false),
|
||||
Video: boolPtr(false),
|
||||
Audio: boolPtr(false),
|
||||
MotionVideoPreview: boolPtr(false),
|
||||
Office: boolPtr(false),
|
||||
PopUp: boolPtr(false),
|
||||
AutoplayMedia: boolPtr(false),
|
||||
DefaultMediaPlayer: true,
|
||||
Folder: boolPtr(false),
|
||||
Models: boolPtr(false),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
u := &users.User{Username: "alice"}
|
||||
ApplyUserDefaults(u)
|
||||
|
||||
want := users.User{
|
||||
DisableSettings: true,
|
||||
LockPassword: true,
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
Permissions: users.Permissions{
|
||||
Api: true,
|
||||
Admin: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Realtime: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Download: false,
|
||||
},
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
EditorQuickSave: true,
|
||||
HideSidebarFileActions: true,
|
||||
DisableQuickToggles: true,
|
||||
DisableSearchOptions: true,
|
||||
StickySidebar: false,
|
||||
HideFilesInTree: true,
|
||||
DarkMode: false,
|
||||
Locale: "de",
|
||||
ViewMode: "list",
|
||||
SingleClick: true,
|
||||
ShowHidden: true,
|
||||
DateFormat: true,
|
||||
GallerySize: 7,
|
||||
ThemeColor: "var(--red)",
|
||||
QuickDownload: true,
|
||||
DisablePreviewExt: ".exe",
|
||||
DisableViewingExt: ".bat",
|
||||
DisableUpdateNotifications: true,
|
||||
DisableOfficePreviewExt: "legacy",
|
||||
DisableOnlyOfficeExt: ".x",
|
||||
CustomTheme: "Night",
|
||||
ShowSelectMultiple: true,
|
||||
DebugOffice: true,
|
||||
DeleteWithoutConfirming: true,
|
||||
DeleteAfterArchive: false,
|
||||
PreferEditorForMarkdown: true,
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 3,
|
||||
UploadChunkSize: 5,
|
||||
DownloadChunkSize: 2,
|
||||
},
|
||||
Preview: users.Preview{
|
||||
DisableHideSidebar: true,
|
||||
Image: false,
|
||||
Video: false,
|
||||
Audio: false,
|
||||
MotionVideoPreview: false,
|
||||
Office: false,
|
||||
PopUp: false,
|
||||
AutoplayMedia: false,
|
||||
DefaultMediaPlayer: true,
|
||||
Folder: false,
|
||||
Models: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want.Permissions, u.Permissions); diff != "" {
|
||||
t.Fatalf("Permissions mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(want.NonAdminEditable, u.NonAdminEditable); diff != "" {
|
||||
t.Fatalf("NonAdminEditable mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if u.DisableSettings != want.DisableSettings {
|
||||
t.Fatalf("DisableSettings: got %v want %v", u.DisableSettings, want.DisableSettings)
|
||||
}
|
||||
if u.LockPassword != want.LockPassword {
|
||||
t.Fatalf("LockPassword: got %v want %v", u.LockPassword, want.LockPassword)
|
||||
}
|
||||
if u.LoginMethod != want.LoginMethod {
|
||||
t.Fatalf("LoginMethod: got %q want %q", u.LoginMethod, want.LoginMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUserDefaults_setsLoginMethodWhenEmpty(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{LoginMethod: "jwt"},
|
||||
}
|
||||
u := &users.User{Username: "x"}
|
||||
ApplyUserDefaults(u)
|
||||
if u.LoginMethod != users.LoginMethodJwt {
|
||||
t.Fatalf("LoginMethod: got %q want jwt", u.LoginMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUserDefaults_preservesLoginMethodWhenAlreadySet(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{LoginMethod: "password"},
|
||||
}
|
||||
u := &users.User{Username: "x", LoginMethod: users.LoginMethodProxy}
|
||||
ApplyUserDefaults(u)
|
||||
if u.LoginMethod != users.LoginMethodProxy {
|
||||
t.Fatalf("LoginMethod: got %q want proxy", u.LoginMethod)
|
||||
}
|
||||
}
|
||||
@@ -106,12 +106,12 @@ type LdapConfig struct {
|
||||
}
|
||||
|
||||
// JwtAuthConfig configures external JWT token authentication
|
||||
// Similar to Grafana's JWT auth: verifies HS* tokens with a shared secret, or RS*/ES* with a PEM public key (or certificate)
|
||||
// Similar to Grafana's JWT auth: accepts external JWT tokens signed with a shared secret
|
||||
// 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"` // Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
|
||||
Secret string `json:"secret"` // secret: shared secret key for verifying JWT token signatures (required)
|
||||
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ func setupFs() {
|
||||
Config.Server.Filesystem.CreateDirectoryPermission = "755"
|
||||
dirPermOctal, _ = strconv.ParseUint("755", 8, 32)
|
||||
}
|
||||
fileutils.SetFsPermissions(uint32(filePermOctal), uint32(dirPermOctal))
|
||||
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
|
||||
|
||||
// Perform mandatory cache directory speed test
|
||||
testCacheDirSpeed()
|
||||
@@ -682,11 +682,10 @@ func setDefaults(generate bool) Settings {
|
||||
CacheDir: "tmp",
|
||||
MaxArchiveSizeGB: 20,
|
||||
IndexSqlConfig: IndexSqlConfig{
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
StartupIntegrityCheck: IndexStartupIntegrityQuickCheck,
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
},
|
||||
Filesystem: Filesystem{
|
||||
CreateFilePermission: "644",
|
||||
@@ -708,19 +707,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,
|
||||
@@ -817,13 +816,6 @@ func loadCustomFavicon() {
|
||||
Env.FaviconPath = validatedPath
|
||||
Env.FaviconIsCustom = true
|
||||
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
|
||||
// PWA and platform PNGs are generated at server startup from this path (SVG uses a raster
|
||||
// sidecar next to the .svg when present; see icons.GeneratePWAIcons).
|
||||
}
|
||||
|
||||
// PWAIconsCacheDir is where startup icon generation writes PNGs (under the server cache dir).
|
||||
func PWAIconsCacheDir() string {
|
||||
return filepath.Join(Config.Server.CacheDir, "icons")
|
||||
}
|
||||
|
||||
func loadLoginIcon() {
|
||||
|
||||
@@ -65,72 +65,54 @@ 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) {
|
||||
d := Config.UserDefaults
|
||||
u.StickySidebar = Config.UserDefaults.StickySidebar
|
||||
u.DisableSettings = Config.UserDefaults.DisableSettings
|
||||
u.HideFilesInTree = Config.UserDefaults.HideFilesInTree
|
||||
u.DeleteAfterArchive = Config.UserDefaults.DeleteAfterArchive
|
||||
|
||||
u.DisableSettings = d.DisableSettings
|
||||
u.LockPassword = d.LockPassword
|
||||
// Handle DarkMode with default - dereference pointer from config
|
||||
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
|
||||
|
||||
u.EditorQuickSave = d.EditorQuickSave
|
||||
u.HideSidebarFileActions = d.HideSidebarFileActions
|
||||
u.DisableQuickToggles = d.DisableQuickToggles
|
||||
u.DisableSearchOptions = d.DisableSearchOptions
|
||||
u.StickySidebar = d.StickySidebar
|
||||
u.HideFilesInTree = d.HideFilesInTree
|
||||
u.DarkMode = boolValueOrDefault(d.DarkMode, true)
|
||||
u.Locale = d.Locale
|
||||
u.ViewMode = d.ViewMode
|
||||
u.SingleClick = d.SingleClick
|
||||
u.ShowHidden = d.ShowHidden
|
||||
u.DateFormat = d.DateFormat
|
||||
u.GallerySize = d.GallerySize
|
||||
u.ThemeColor = d.ThemeColor
|
||||
u.QuickDownload = d.QuickDownload
|
||||
u.DisablePreviewExt = d.DisablePreviewExt
|
||||
u.DisableViewingExt = d.DisableViewingExt
|
||||
u.DisableUpdateNotifications = d.DisableUpdateNotifications
|
||||
u.DisableOfficePreviewExt = d.DisableOfficePreviewExt
|
||||
u.DisableOnlyOfficeExt = d.DisableOnlyOfficeExt
|
||||
u.CustomTheme = d.CustomTheme
|
||||
u.ShowSelectMultiple = d.ShowSelectMultiple
|
||||
u.DebugOffice = d.DebugOffice
|
||||
u.DeleteWithoutConfirming = d.DeleteWithoutConfirming
|
||||
u.DeleteAfterArchive = d.DeleteAfterArchive
|
||||
u.PreferEditorForMarkdown = d.PreferEditorForMarkdown
|
||||
u.FileLoading = d.FileLoading
|
||||
u.Locale = Config.UserDefaults.Locale
|
||||
u.ViewMode = Config.UserDefaults.ViewMode
|
||||
u.SingleClick = Config.UserDefaults.SingleClick
|
||||
|
||||
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 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)
|
||||
|
||||
if u.Username == "anonymous" {
|
||||
return
|
||||
}
|
||||
// Handle Preview - convert from pointer-based defaults to regular bools
|
||||
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
|
||||
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
|
||||
u.Preview.Audio = boolValueOrDefault(Config.UserDefaults.Preview.Audio, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
|
||||
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
|
||||
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
|
||||
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
|
||||
u.Preview.Models = boolValueOrDefault(Config.UserDefaults.Preview.Models, true)
|
||||
|
||||
u.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 {
|
||||
u.ShowHidden = Config.UserDefaults.ShowHidden
|
||||
u.DateFormat = Config.UserDefaults.DateFormat
|
||||
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
|
||||
u.ThemeColor = Config.UserDefaults.ThemeColor
|
||||
u.GallerySize = Config.UserDefaults.GallerySize
|
||||
u.QuickDownload = Config.UserDefaults.QuickDownload
|
||||
u.LockPassword = Config.UserDefaults.LockPassword
|
||||
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
|
||||
u.FileLoading = Config.UserDefaults.FileLoading
|
||||
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
|
||||
u.PreferEditorForMarkdown = Config.UserDefaults.PreferEditorForMarkdown
|
||||
if len(u.Scopes) == 0 && u.Username != "anonymous" {
|
||||
for _, source := range Config.Server.Sources {
|
||||
if source.Config.DefaultEnabled {
|
||||
u.Scopes = append(u.Scopes, users.SourceScope{
|
||||
|
||||
@@ -36,6 +36,10 @@ 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 {
|
||||
@@ -72,22 +76,11 @@ 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.
|
||||
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)
|
||||
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.
|
||||
}
|
||||
|
||||
type Integrations struct {
|
||||
|
||||
@@ -17,11 +17,6 @@ type IndexInfo struct {
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
|
||||
// Disk usage (cached from periodic GetIndexInfo / partition probes; aligns with ReducedIndex Stats)
|
||||
UsedAsIndexed uint64 `json:"used"`
|
||||
UsedDisk uint64 `json:"usedAlt"`
|
||||
DiskTotal uint64 `json:"total"`
|
||||
|
||||
// Scanner information - map of scanner path to scanner stats
|
||||
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
|
||||
}
|
||||
|
||||
@@ -211,9 +211,5 @@ 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
|
||||
}
|
||||
+100
-153
@@ -2,7 +2,6 @@ package sql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -91,8 +90,7 @@ 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 {
|
||||
mode := normalizeStartupIntegrityMode(settings.Config.Server.IndexSqlConfig.StartupIntegrityCheck)
|
||||
if err := idxDB.runStartupIntegrityCheck(mode); err != nil {
|
||||
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); 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
|
||||
@@ -114,73 +112,10 @@ 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)
|
||||
@@ -298,70 +233,28 @@ func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) erro
|
||||
return err
|
||||
}
|
||||
|
||||
// dbTransientOpAttempts is how many times to run a bulk DB op that may hit SQLITE_BUSY before giving up.
|
||||
const dbTransientOpAttempts = 2
|
||||
|
||||
const dbTransientRetryBackoff = 50 * time.Millisecond
|
||||
|
||||
// BulkInsertItems inserts multiple items in a single transaction for a specific source.
|
||||
// Database errors (busy/locked) are treated as soft failures - the filesystem is the source of truth.
|
||||
// Returns nil on success or soft failure (busy/locked), error only on hard failures.
|
||||
// Transient busy/locked errors retry once before giving up (see dbTransientOpAttempts).
|
||||
func (db *IndexDB) BulkInsertItems(source string, items []*iteminfo.FileInfo) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
for attempt := 1; attempt <= dbTransientOpAttempts; attempt++ {
|
||||
ok, transient, err := db.attemptBulkInsert(source, items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
if attempt > 1 {
|
||||
logger.Infof("[DB_TX] BulkInsertItems: succeeded on retry (%d items, attempt %d, %v)",
|
||||
len(items), attempt, time.Since(startTime))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if transient && attempt < dbTransientOpAttempts {
|
||||
logger.Warningf("[DB_TX] BulkInsertItems: transient DB error (attempt %d/%d, %d items), retrying after %v",
|
||||
attempt, dbTransientOpAttempts, len(items), dbTransientRetryBackoff)
|
||||
time.Sleep(dbTransientRetryBackoff)
|
||||
continue
|
||||
}
|
||||
if transient {
|
||||
logger.Warningf("[DB_TX] BulkInsertItems: still failing after %d attempts (%d items, %v) — batch dropped",
|
||||
dbTransientOpAttempts, len(items), time.Since(startTime))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil // defensive if dbTransientOpAttempts is ever 0 and loop never runs
|
||||
}
|
||||
|
||||
// attemptBulkInsert runs one transactional bulk insert.
|
||||
// Returns success=true when committed; transient=true when SQLITE_BUSY-like errors allow a retry (mutex released).
|
||||
func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo) (success bool, transient bool, err error) {
|
||||
tx, err := db.BeginTransaction()
|
||||
if err != nil {
|
||||
// Soft failure: DB is busy or locked, skip this update
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
db.mu.Unlock()
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked): %v", err)
|
||||
return false, true, nil
|
||||
db.mu.Unlock() // Release mutex on error
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: BeginTransaction failed (DB busy/locked), skipping - took %v", time.Since(startTime))
|
||||
return nil // Non-fatal: filesystem will be used as fallback
|
||||
}
|
||||
db.mu.Unlock()
|
||||
return false, false, err
|
||||
db.mu.Unlock() // Release mutex on error
|
||||
return err // Hard failure: something is wrong with the DB
|
||||
}
|
||||
defer db.mu.Unlock() // Ensure mutex is always released
|
||||
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
db.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Always update all fields including last_updated, even if nothing changed
|
||||
// This ensures last_updated stays current for stale entry detection
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -378,12 +271,14 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
|
||||
`)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: Prepare busy/locked: %v", err)
|
||||
return false, true, nil
|
||||
// With EXCLUSIVE locking and mutex, this shouldn't happen.
|
||||
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
|
||||
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Prepare: %v", err)
|
||||
return nil
|
||||
}
|
||||
return false, false, err
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
defer stmt.Close() // Ensure statement is always closed
|
||||
|
||||
nowUnix := time.Now().Unix()
|
||||
for _, info := range items {
|
||||
@@ -403,23 +298,28 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
|
||||
)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: Exec busy/locked during insert: %v", err)
|
||||
return false, true, nil
|
||||
// With EXCLUSIVE locking and mutex, this shouldn't happen.
|
||||
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
|
||||
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Exec: %v", err)
|
||||
return nil
|
||||
}
|
||||
logger.Errorf("[DB] BulkInsertItems: Exec failed for path=%s, source=%s, error=%v", info.Path, source, err)
|
||||
return false, false, err
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Try to commit
|
||||
if err := tx.Commit(); err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
logger.Debugf("[DB_TX] BulkInsertItems: Commit busy/locked: %v", err)
|
||||
return false, true, nil
|
||||
// With EXCLUSIVE locking and mutex, this shouldn't happen.
|
||||
// Log as warning to surface potential issues (another process accessing DB, bug, etc.)
|
||||
logger.Errorf("[DB] BulkInsertItems: Unexpected busy/lock error during Commit: %v", err)
|
||||
return nil
|
||||
}
|
||||
return false, false, err
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return true, false, nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isBusyError checks if an error is SQLITE_BUSY (error code 5)
|
||||
@@ -509,6 +409,51 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetHasPreviewBatch retrieves hasPreview status for multiple directory paths in a single query.
|
||||
// This is optimized for the N+1 query pattern in GetDirInfo where we need hasPreview for all subdirectories.
|
||||
// Returns map[path]hasPreview. Missing paths in the result indicate they're not in the database yet.
|
||||
func (db *IndexDB) GetHasPreviewBatch(source string, paths []string) (map[string]bool, error) {
|
||||
if len(paths) == 0 {
|
||||
return make(map[string]bool), nil
|
||||
}
|
||||
|
||||
// Build query with IN clause - only select path and has_preview for efficiency
|
||||
placeholders := make([]string, len(paths))
|
||||
args := make([]interface{}, len(paths)+1)
|
||||
args[0] = source
|
||||
for i, path := range paths {
|
||||
placeholders[i] = "?"
|
||||
args[i+1] = path
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT path, has_preview
|
||||
FROM index_items WHERE source = ? AND path IN (%s) AND is_dir = 1
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
// Soft failure: DB is busy or locked, return empty map
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
return make(map[string]bool), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var path string
|
||||
var hasPreview bool
|
||||
if err := rows.Scan(&path, &hasPreview); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[path] = hasPreview
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDirectoryChildren retrieves all children of a directory for a specific source.
|
||||
func (db *IndexDB) GetDirectoryChildren(source, dirPath string) ([]*iteminfo.FileInfo, error) {
|
||||
query := `
|
||||
@@ -1067,31 +1012,17 @@ func (db *IndexDB) UpdateFolderSizesIfChanged(source string, pathSizes map[strin
|
||||
args = append(args, path)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
for attempt := 1; attempt <= dbTransientOpAttempts; attempt++ {
|
||||
result, err := db.Exec(query, args...)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
if attempt < dbTransientOpAttempts {
|
||||
logger.Warningf("[DB_TX] UpdateFolderSizesIfChanged: busy/locked (attempt %d/%d), retrying after %v: %v",
|
||||
attempt, dbTransientOpAttempts, dbTransientRetryBackoff, err)
|
||||
time.Sleep(dbTransientRetryBackoff)
|
||||
continue
|
||||
}
|
||||
logger.Warningf("[DB_TX] UpdateFolderSizesIfChanged: still busy after %d attempts (%d paths, %v) — skipped",
|
||||
dbTransientOpAttempts, len(pathSizes), time.Since(startTime))
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
result, err := db.Exec(query, args...)
|
||||
if err != nil {
|
||||
if isBusyError(err) || isTransactionError(err) {
|
||||
logger.Debugf("[DB_TX] UpdateFolderSizesIfChanged: DB busy/locked, skipping update")
|
||||
return 0, nil // Non-fatal: sizes will be synced on next scan
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if attempt > 1 {
|
||||
logger.Infof("[DB_TX] UpdateFolderSizesIfChanged: succeeded on retry (%d rows, attempt %d, %v)",
|
||||
n, attempt, time.Since(startTime))
|
||||
}
|
||||
return n, nil
|
||||
return 0, err
|
||||
}
|
||||
return 0, nil // defensive: unreachable when dbTransientOpAttempts >= 1
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
return rowsAffected, nil
|
||||
}
|
||||
|
||||
// LoadFolderSizes loads all folder sizes for a source from the database
|
||||
@@ -1267,6 +1198,23 @@ 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.
|
||||
@@ -1324,7 +1272,6 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
|
||||
return nil, err
|
||||
}
|
||||
info.ModTime = time.Unix(modTime, 0)
|
||||
info.IsDir = isDir
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -74,6 +74,11 @@ 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
|
||||
}
|
||||
@@ -81,21 +86,11 @@ 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 {
|
||||
@@ -118,16 +113,10 @@ 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, false)
|
||||
err = files.MakeUserDirs(user, true)
|
||||
if err != nil {
|
||||
return err
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
}
|
||||
// converting scopes to map of paths intead of names (names can change)
|
||||
@@ -197,7 +186,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
|
||||
return err
|
||||
}
|
||||
user.Scopes = adjustedScopes
|
||||
err = files.MakeUserDirs(user, true)
|
||||
err = files.MakeUserDirs(user, disableScopeChange)
|
||||
if err != nil {
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ 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
|
||||
}
|
||||
@@ -120,14 +121,12 @@ type NonAdminEditable struct {
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
|
||||
+37
-37
@@ -4,35 +4,35 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.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.2
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/kovidgoyal/imaging v1.8.21
|
||||
github.com/mattn/go-sqlite3 v1.14.42
|
||||
github.com/gtsteffaniak/go-logger v1.0.2
|
||||
github.com/kovidgoyal/imaging v1.8.20
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/crypto v0.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/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.44.0
|
||||
golang.org/x/tools v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.50.0
|
||||
modernc.org/sqlite v1.47.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.1 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // 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.4 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.65.1 // indirect
|
||||
github.com/air-verse/air v1.64.5 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
github.com/alexkohler/prealloc v1.1.0 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.2 // 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.19.0 // indirect
|
||||
github.com/fatih/color v1.18.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.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.23.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-openapi/jsonreference v0.21.5 // indirect
|
||||
github.com/go-openapi/spec v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
||||
github.com/go-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-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-20260415063119-550b242b3150 // indirect
|
||||
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a // 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.11.4 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1 // 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.10.0 // indirect
|
||||
github.com/kisielk/errcheck v1.9.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.21 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/mgechev/revive v1.15.0 // indirect
|
||||
github.com/mgechev/revive v1.14.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.6.0 // indirect
|
||||
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
|
||||
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect
|
||||
github.com/securego/gosec/v2 v2.23.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sivchari/containedctx v1.0.3 // indirect
|
||||
github.com/sonatard/noctx v0.5.1 // indirect
|
||||
github.com/sonatard/noctx v0.4.0 // indirect
|
||||
github.com/sourcegraph/go-diff v0.7.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
@@ -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.1 // indirect
|
||||
github.com/uudashr/gocognit v1.2.0 // 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.36.0 // indirect
|
||||
golang.org/x/text v0.35.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.72.1 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
|
||||
+87
-86
@@ -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.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
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/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.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
|
||||
github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/air-verse/air v1.65.1 h1:G4hy4YemKrIcGAvYMor+86PhxdtBEq0n9ebJ4RoYy8w=
|
||||
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
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/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.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M=
|
||||
github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
|
||||
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
|
||||
github.com/alexkohler/prealloc v1.0.2/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.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-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-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.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
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/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.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
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-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.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||
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/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.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-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-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.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
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-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-20260415063119-550b242b3150 h1:F5CQANtNulclG+xJt370ohUujAEHq2XNBBAjkoSukQI=
|
||||
github.com/golang/geo v0.0.0-20260415063119-550b242b3150/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a h1:7f/cr+n7Jtd7mzCLrx47sdVIGabpSSxCHrf6fJczDp0=
|
||||
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a/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.11.4 h1:GK+UlZBN5y7rh2PBnHA93XLSX6RaF7uhzJQ3JwU1wuA=
|
||||
github.com/golangci/golangci-lint/v2 v2.11.4/go.mod h1:ODQDCASMA3VqfZYIbbQLpTRTzV7O/vjmIRF6u8NyFwI=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
|
||||
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.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
|
||||
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/gtsteffaniak/go-logger v1.0.2 h1:S4AgR7yDu2geD8y2D9NyJVOBqpZRnl37r2mBi7GDNR0=
|
||||
github.com/gtsteffaniak/go-logger v1.0.2/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.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
|
||||
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
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/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.21 h1:95S2+dowTeKJJHNpf6lnScvIennTr2H0zQotu+ptNQw=
|
||||
github.com/kovidgoyal/imaging v1.8.21/go.mod h1:976F+zjiQeZ7sd87Pxlm0a64S/w9bImSIWg3sSk1rdQ=
|
||||
github.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=
|
||||
github.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=
|
||||
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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
|
||||
github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A=
|
||||
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/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.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg=
|
||||
github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
|
||||
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/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.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
|
||||
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
|
||||
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
|
||||
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
|
||||
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.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
|
||||
github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
|
||||
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
|
||||
github.com/sonatard/noctx v0.4.0/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.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
|
||||
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
|
||||
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/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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
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/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.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
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/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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
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/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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
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/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,11 +1094,12 @@ 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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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/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=
|
||||
@@ -1115,8 +1116,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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
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/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=
|
||||
@@ -1172,8 +1173,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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
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/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=
|
||||
@@ -1290,10 +1291,10 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc=
|
||||
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.33.0 h1:dspBCm75jsj8Y/ufwAMVfe375L2iYdMyQ2QG/v3hL54=
|
||||
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
|
||||
modernc.org/cc/v4 v4.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/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
@@ -1302,18 +1303,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.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
|
||||
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
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.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
|
||||
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
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=
|
||||
|
||||
+4
-20
@@ -147,22 +147,6 @@ 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.
|
||||
@@ -185,8 +169,8 @@ func listApiTokensHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
AuthTokensFrontend = append(AuthTokensFrontend, AuthTokenFrontend{
|
||||
Token: token.Token,
|
||||
Name: name,
|
||||
IssuedAt: authTokenIssuedUnix(token),
|
||||
ExpiresAt: authTokenExpiresUnix(token),
|
||||
IssuedAt: token.RegisteredClaims.IssuedAt.Unix(),
|
||||
ExpiresAt: token.RegisteredClaims.ExpiresAt.Unix(),
|
||||
Permissions: token.Permissions,
|
||||
})
|
||||
}
|
||||
@@ -221,8 +205,8 @@ func getApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
AuthTokenFrontendResponse := AuthTokenFrontend{
|
||||
Token: tokenInfo.Token,
|
||||
Name: name,
|
||||
IssuedAt: authTokenIssuedUnix(tokenInfo),
|
||||
ExpiresAt: authTokenExpiresUnix(tokenInfo),
|
||||
IssuedAt: tokenInfo.IssuedAt,
|
||||
ExpiresAt: tokenInfo.ExpiresAt,
|
||||
Permissions: tokenInfo.Permissions,
|
||||
}
|
||||
return renderJSON(w, r, AuthTokenFrontendResponse)
|
||||
|
||||
+70
-286
@@ -8,14 +8,10 @@ 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"
|
||||
@@ -105,34 +101,34 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullDestFile := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestFile, d.user.Username) {
|
||||
fullDest := utils.JoinPathAsUnix(userScopeTo, req.Destination)
|
||||
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDest, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
|
||||
}
|
||||
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)
|
||||
// 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 = "/"
|
||||
}
|
||||
parentReal, _, err := idxTo.GetRealPath(fullDestParent)
|
||||
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)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination parent path invalid: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
|
||||
}
|
||||
if err = os.MkdirAll(parentReal, fileutils.EffectiveDirPerm()); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("cannot create destination parent directory: %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)
|
||||
}
|
||||
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 == "" {
|
||||
destLower := strings.ToLower(req.Destination)
|
||||
if strings.HasSuffix(destLower, ".tar.gz") {
|
||||
ext := strings.ToLower(filepath.Ext(destRealPath))
|
||||
if ext == ".gz" && len(destRealPath) > 3 && strings.HasSuffix(strings.ToLower(destRealPath), ".tar.gz") {
|
||||
format = "tar.gz"
|
||||
} else if strings.HasSuffix(destLower, ".zip") || filepath.Ext(req.Destination) == ".zip" {
|
||||
} else if ext == ".zip" {
|
||||
format = "zip"
|
||||
} else {
|
||||
format = "zip"
|
||||
@@ -174,7 +170,7 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
}
|
||||
|
||||
var createErr error
|
||||
file, err := os.OpenFile(destFileReal, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.EffectiveFilePerm())
|
||||
file, err := os.OpenFile(destRealPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -301,19 +297,22 @@ 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)
|
||||
}
|
||||
destReal, _, err := idxTo.GetRealPath(fullDestPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination path invalid: %v", err)
|
||||
// Destination may not exist yet; resolve parent dir then build destination path.
|
||||
destDir := filepath.Dir(req.Destination)
|
||||
if destDir == "." {
|
||||
destDir = "/"
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
if !destInfo.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination must be a directory: %s", req.Destination)
|
||||
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("destination directory 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)
|
||||
}
|
||||
|
||||
info, err := os.Stat(archiveReal)
|
||||
@@ -417,21 +416,16 @@ func addFile(source string, path string, d *requestContext, tarWriter *tar.Write
|
||||
|
||||
if fileInfo.IsDir() {
|
||||
if tarWriter != nil {
|
||||
header, err := tar.FileInfoHeader(fileInfo, "")
|
||||
if err != nil {
|
||||
return err
|
||||
header := &tar.Header{
|
||||
Name: relPath + "/",
|
||||
Mode: int64(fileutils.PermDir),
|
||||
Typeflag: tar.TypeDir,
|
||||
ModTime: fileInfo.ModTime(),
|
||||
}
|
||||
header.Name = relPath + "/"
|
||||
return tarWriter.WriteHeader(header)
|
||||
}
|
||||
if zipWriter != nil {
|
||||
zh, err := zip.FileInfoHeader(fileInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zh.Name = relPath + "/"
|
||||
zh.Method = zip.Store
|
||||
_, err = zipWriter.CreateHeader(zh)
|
||||
_, err := zipWriter.Create(relPath + "/")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -648,53 +642,13 @@ type unarchiveRequest struct {
|
||||
DeleteAfter bool `json:"deleteAfter"`
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
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).
|
||||
// safeExtractPath ensures name does not escape destDir (no ".." or absolute).
|
||||
func safeExtractPath(destDir, name string) (string, error) {
|
||||
relSlash, err := normalizeArchiveEntryName(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid entry path: %q: %w", name, err)
|
||||
clean := filepath.Clean(name)
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", name)
|
||||
}
|
||||
// Convert to platform separators so filepath.Join and os.Mkdir work correctly
|
||||
abs := filepath.Join(destDir, filepath.FromSlash(relSlash))
|
||||
abs := filepath.Join(destDir, clean)
|
||||
destAbs, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -703,130 +657,12 @@ func safeExtractPath(destDir, name string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 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)
|
||||
if !strings.HasPrefix(entryAbs, destAbs) {
|
||||
return "", fmt.Errorf("invalid entry path: %s", 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 {
|
||||
@@ -834,82 +670,34 @@ func extractZip(archivePath, destDir string) error {
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Central directory order is arbitrary. Some zips (e.g. Windows driver packs) use names that
|
||||
// are similar length at different tree depths, so byte length is a poor order key. Use the
|
||||
// number of path segments in the *normalized* entry name: deeper trees first, then
|
||||
// lexicographic, so parent dirs exist before shorter entries that might be mis-tagged.
|
||||
type orderedName struct {
|
||||
f *zip.File
|
||||
rel string
|
||||
depth int
|
||||
}
|
||||
ordered := make([]orderedName, 0, len(r.File))
|
||||
for _, f := range r.File {
|
||||
rel, nerr := normalizeArchiveEntryName(f.Name)
|
||||
if nerr != nil {
|
||||
rel = strings.TrimLeft(strings.ReplaceAll(strings.TrimSpace(f.Name), "\\", "/"), "/")
|
||||
}
|
||||
depth := 0
|
||||
if rel != "" {
|
||||
depth = strings.Count(rel, "/") + 1
|
||||
}
|
||||
ordered = append(ordered, orderedName{f: f, rel: rel, depth: depth})
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
if ordered[i].depth != ordered[j].depth {
|
||||
return ordered[i].depth > ordered[j].depth
|
||||
}
|
||||
if ordered[i].rel != ordered[j].rel {
|
||||
return ordered[i].rel < ordered[j].rel
|
||||
}
|
||||
return ordered[i].f.Name < ordered[j].f.Name
|
||||
})
|
||||
|
||||
for _, o := range ordered {
|
||||
f := o.f
|
||||
var destPath string
|
||||
destPath, err = safeExtractPath(destDir, f.Name)
|
||||
destPath, err := safeExtractPath(destDir, f.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
if f.FileInfo().IsDir() {
|
||||
if err = os.MkdirAll(destPath, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
if err = os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !mode.IsRegular() {
|
||||
continue
|
||||
}
|
||||
|
||||
var rc io.ReadCloser
|
||||
rc, err = f.Open()
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = extractArchivedRegularFile(destPath, mode, f.Modified, rc); err != nil {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, rc)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -938,33 +726,29 @@ func extractTarGz(archivePath, destDir string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var destPath string
|
||||
destPath, err = safeExtractPath(destDir, h.Name)
|
||||
destPath, err := safeExtractPath(destDir, h.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
mode := h.FileInfo().Mode()
|
||||
if err = extractArchivedDir(destPath, mode, h.ModTime); err != nil {
|
||||
if err := os.MkdirAll(destPath, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
mode := h.FileInfo().Mode()
|
||||
if err = extractArchivedRegularFile(destPath, mode, h.ModTime, io.NopCloser(tr)); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeSymlink:
|
||||
if err = extractArchivedSymlink(destDir, destPath, h.Linkname, h.ModTime); err != nil {
|
||||
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if h.Size > 0 {
|
||||
if _, err = io.CopyN(io.Discard, tr, h.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeArchiveEntryName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "simple forward slashes",
|
||||
input: "DOCS/guide.pdf",
|
||||
want: "DOCS/guide.pdf",
|
||||
},
|
||||
{
|
||||
name: "Windows backslashes become logical path",
|
||||
input: `DOCS\QUICK\chs.gif`,
|
||||
want: "DOCS/QUICK/chs.gif",
|
||||
},
|
||||
{
|
||||
name: "Intel-style leading backslash stripped so path stays relative",
|
||||
input: `\PRO1000\Winx64\NDIS65\e1c65x64.cat`,
|
||||
want: "PRO1000/Winx64/NDIS65/e1c65x64.cat",
|
||||
},
|
||||
{
|
||||
name: "leading forward slashes stripped",
|
||||
input: "/PRO1000/Winx64/readme.txt",
|
||||
want: "PRO1000/Winx64/readme.txt",
|
||||
},
|
||||
{
|
||||
name: "directory entry with trailing slash",
|
||||
input: `PRO1000\Winx64\`,
|
||||
want: "PRO1000/Winx64",
|
||||
},
|
||||
{
|
||||
name: "mixed redundant segments cleaned",
|
||||
input: "a/./b/../c/file",
|
||||
want: "a/c/file",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantErr: "empty path",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " ",
|
||||
wantErr: "empty path",
|
||||
},
|
||||
{
|
||||
name: "UNC rejected",
|
||||
input: `//server/share/file`,
|
||||
wantErr: "UNC or invalid path",
|
||||
},
|
||||
{
|
||||
name: "double slash prefix after backslash replace (UNC-style)",
|
||||
input: `\\?\C:\temp\file`,
|
||||
wantErr: "UNC or invalid path",
|
||||
},
|
||||
{
|
||||
name: "Windows drive absolute rejected",
|
||||
input: `C:/Windows/system32`,
|
||||
wantErr: "absolute path in archive",
|
||||
},
|
||||
{
|
||||
name: "lowercase drive rejected",
|
||||
input: `c:/data/file.txt`,
|
||||
wantErr: "absolute path in archive",
|
||||
},
|
||||
{
|
||||
name: "bare dot-dot",
|
||||
input: "..",
|
||||
wantErr: "path traversal",
|
||||
},
|
||||
{
|
||||
name: "prefixed dot-dot",
|
||||
input: "../etc/passwd",
|
||||
wantErr: "path traversal",
|
||||
},
|
||||
{
|
||||
name: "traversal after path clean",
|
||||
input: "safe/../../outside",
|
||||
wantErr: "path traversal after clean",
|
||||
},
|
||||
{
|
||||
name: "only dots and slashes",
|
||||
input: "./",
|
||||
wantErr: "empty path after clean",
|
||||
},
|
||||
{
|
||||
name: "path cleans to nothing",
|
||||
input: "x/../.",
|
||||
wantErr: "empty path after clean",
|
||||
},
|
||||
{
|
||||
name: "spaces preserved in file name",
|
||||
input: "My Folder/read me.txt",
|
||||
want: "My Folder/read me.txt",
|
||||
},
|
||||
{
|
||||
name: "deep relative tree",
|
||||
input: "a/b/c/d/e/f/g.bin",
|
||||
want: "a/b/c/d/e/f/g.bin",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeArchiveEntryName(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil (result %q)", tt.wantErr, got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("normalizeArchiveEntryName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeArchiveEntryName_backslashInFileNameOnUnix documents that after normalization
|
||||
// a single backslash in the raw name is never a path separator: it becomes a slash in the
|
||||
// logical path (portable), so segments split correctly on every GOOS in safeExtractPath.
|
||||
func TestNormalizeArchiveEntryName_unifiesBackslashes(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := normalizeArchiveEntryName(`folder\file.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "folder/file.txt"; got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeExtractPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
dest := t.TempDir()
|
||||
dest, err := filepath.Abs(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("file under dest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := safeExtractPath(dest, `DOCS\Adapter_User_Guide.pdf`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(dest, "DOCS", "Adapter_User_Guide.pdf")
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leading backslash in zip name stays under dest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := safeExtractPath(dest, `\sub\file.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(got, dest+string(filepath.Separator)) {
|
||||
t.Fatalf("result %q should be under dest %q", got, dest)
|
||||
}
|
||||
rel, err := filepath.Rel(dest, got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel != filepath.Join("sub", "file.txt") {
|
||||
t.Fatalf("Rel = %q, want sub%cfile.txt", rel, filepath.Separator)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects path that escapes dest", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := safeExtractPath(dest, "../../../x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for parent segments in archive name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid entry path") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects when normalize fails", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := safeExtractPath(dest, "..")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid entry path") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSafeExtractPath_rejectsParentTraversal ensures ".." in the archive name is rejected
|
||||
// by normalizeArchiveEntryName, not written under dest.
|
||||
func TestSafeExtractPath_rejectsParentTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
dest := t.TempDir()
|
||||
_, err := safeExtractPath(dest, `pkg/../../../secret`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path traversal in archive name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeExtractPath_destJoinDoesNotDropBase verifies that a previously problematic pattern
|
||||
// (root-relative second segment) does not throw away dest after normalization.
|
||||
func TestSafeExtractPath_destJoinDoesNotDropBase(t *testing.T) {
|
||||
t.Parallel()
|
||||
dest := t.TempDir()
|
||||
out, err := safeExtractPath(dest, `a\b\c.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(out, dest+string(filepath.Separator)) {
|
||||
t.Fatalf("expected path under %q, got %q", dest, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractZip_nestedBackslashes extracts a Windows-style path in a zip (backslashes) into
|
||||
// nested directories under dest. This matches many vendor driver zips.
|
||||
func TestExtractZip_nestedBackslashes(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmp := t.TempDir()
|
||||
zipPath := filepath.Join(tmp, "test.zip")
|
||||
out := filepath.Join(tmp, "out")
|
||||
if err := os.MkdirAll(out, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
w, err := zw.Create(`DOCS\QUICK\readme.txt`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = w.Write([]byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err = extractZip(zipPath, out); err != nil {
|
||||
t.Fatalf("extractZip: %v", err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(out, "DOCS", "QUICK", "readme.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != "hello" {
|
||||
t.Fatalf("content = %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmarks (optional, keep small)
|
||||
func BenchmarkNormalizeArchiveEntryName(b *testing.B) {
|
||||
const name = `PRO1000\Winx64\NDIS65\e1c65x64.cat`
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = normalizeArchiveEntryName(name)
|
||||
}
|
||||
}
|
||||
@@ -99,8 +99,6 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
dispositionType := "attachment"
|
||||
if r.URL.Query().Get("inline") == "true" {
|
||||
dispositionType = "inline"
|
||||
// Inline SVG (and similar) can execute embedded scripts when opened as a top-level document; match upstream filebrowser mitigation.
|
||||
w.Header().Set("Content-Security-Policy", "script-src 'none'")
|
||||
}
|
||||
|
||||
// standard: ASCII-only safe fallback
|
||||
|
||||
@@ -219,7 +219,6 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
|
||||
}
|
||||
if isText {
|
||||
response.IsText = true
|
||||
// Read the last N lines for text files only
|
||||
content, err := readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
|
||||
@@ -92,11 +92,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// ========================================
|
||||
// Auth Routes - /api/auth/
|
||||
// ========================================
|
||||
api.HandleFunc("POST /auth/login", loginHelper(loginHandler))
|
||||
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
|
||||
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
|
||||
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
|
||||
api.HandleFunc("POST /auth/otp/generate", withOrWithoutUser(generateOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/verify", withOrWithoutUser(verifyOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(generateOTPHandler))
|
||||
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(verifyOTPHandler))
|
||||
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
|
||||
api.HandleFunc("POST /auth/token", withUser(createApiTokenHandler))
|
||||
api.HandleFunc("DELETE /auth/token", withUser(deleteApiTokenHandler))
|
||||
@@ -119,7 +119,6 @@ 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))
|
||||
@@ -129,7 +128,6 @@ 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))
|
||||
@@ -150,7 +148,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", withUser(shareGetHandler))
|
||||
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
|
||||
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
|
||||
api.HandleFunc("PATCH /share", withPermShare(sharePatchHandler))
|
||||
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
|
||||
@@ -173,11 +171,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("GET /tools/fileWatcher/sse", withUser(fileWatchSSEHandler))
|
||||
|
||||
// ========================================
|
||||
// Media Routes - /api/media/ (with public routes)
|
||||
// Media Routes - /api/media/
|
||||
// ========================================
|
||||
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)
|
||||
|
||||
@@ -80,11 +80,17 @@ func TestPickUserEntry(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := pickUserEntry(tt.entries)
|
||||
if tt.wantNil && got != nil {
|
||||
t.Errorf("pickUserEntry() = %v, want nil", got)
|
||||
if tt.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("pickUserEntry() = %v, want nil", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !tt.wantNil && got == nil {
|
||||
t.Errorf("pickUserEntry() = nil, want non-nil entry")
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ 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"
|
||||
@@ -101,77 +99,3 @@ 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)
|
||||
}
|
||||
+32
-23
@@ -53,11 +53,7 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
|
||||
func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
inputPath := r.URL.Query().Get("path")
|
||||
path, err := utils.SanitizeUserPath(inputPath)
|
||||
if err != nil && inputPath != "" {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
// Get the file link by hash
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
@@ -120,9 +116,7 @@ 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")) ||
|
||||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) {
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") || r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items") {
|
||||
return fn(w, r, data)
|
||||
}
|
||||
file, err := FileInfoFasterFunc(utils.FileOptions{
|
||||
@@ -318,13 +312,32 @@ func withoutUserHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
|
||||
// allow user without OTP to pass
|
||||
func LoginHelper(disableOtp bool, fn handleFunc) handleFunc {
|
||||
func userWithoutOTPhelper(fn handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
|
||||
if config.Auth.Methods.ProxyAuth.Enabled {
|
||||
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
|
||||
if proxyUser != "" {
|
||||
return getProxyUser(w, r, d, fn, proxyUser)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,19 +361,15 @@ func LoginHelper(disableOtp bool, 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
|
||||
}
|
||||
// OTP routes: password only (do not mutate stored *JSONAuth).
|
||||
if ja, ok := auther.(*auth.JSONAuth); ok {
|
||||
x := *ja
|
||||
x.DisableOtp = disableOtp
|
||||
auther = &x
|
||||
}
|
||||
// Authenticate the user based on the request
|
||||
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
|
||||
}
|
||||
@@ -663,8 +672,8 @@ func withoutUser(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(withoutUserHelper(fn))
|
||||
}
|
||||
|
||||
func loginHelper(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(LoginHelper(false, fn))
|
||||
func userWithoutOTP(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandler(userWithoutOTPhelper(fn))
|
||||
}
|
||||
|
||||
func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
|
||||
|
||||
+24
-101
@@ -42,112 +42,34 @@ func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract groups if configured (supports nested claims like "resource_access.filebrowser-client.roles")
|
||||
// Extract groups if configured
|
||||
if u.groupsClaim != "" {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
u.userInfo.Claims = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// oidcLoginHandler initiates OIDC login.
|
||||
@@ -255,7 +177,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// --- Attempt to process ID Token ---
|
||||
if ok && rawIDToken != "" {
|
||||
logger.Debugf("ID token found in token response, attempting verification. Raw ID Token: %s", rawIDToken)
|
||||
logger.Debug("ID token found in token response, attempting verification.")
|
||||
|
||||
// Verify the ID token
|
||||
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
|
||||
@@ -280,6 +202,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
claimsFromIDToken = true
|
||||
}
|
||||
}
|
||||
logger.Debugf("Failed to verify ID token: %v", verify_err)
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
@@ -114,64 +114,6 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
Groups: []string{"admins"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested single-level claim",
|
||||
jsonData: `{"username":"john","custom":{"groups":["admin","users"]}}`,
|
||||
groupsClaim: "custom.groups",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"username": "john",
|
||||
"custom": map[string]interface{}{
|
||||
"groups": []interface{}{"admin","users"},
|
||||
},
|
||||
},
|
||||
Groups: []string{"admin", "users"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keycloak resource_access nested structure",
|
||||
jsonData: `{"sub":"user123","resource_access":{"filebrowser-client":{"roles":["admin","editor","viewer"]}}}`,
|
||||
groupsClaim: "resource_access.filebrowser-client.roles",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"sub": "user123",
|
||||
"resource_access": map[string]interface{}{
|
||||
"filebrowser-client": map[string]interface{}{
|
||||
"roles": []interface{}{"admin","editor","viewer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Groups: []string{"admin", "editor", "viewer"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested claim with single string value",
|
||||
jsonData: `{"username":"bob","auth":{"group":"admin"}}`,
|
||||
groupsClaim: "auth.group",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"username": "bob",
|
||||
"auth": map[string]interface{}{
|
||||
"group": "admin",
|
||||
},
|
||||
},
|
||||
Groups: []string{"admin"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid nested path returns empty groups",
|
||||
jsonData: `{"username":"alice","custom":{"roles":["user"]}}`,
|
||||
groupsClaim: "custom.nonexistent.roles",
|
||||
expected: userInfo{
|
||||
Claims: map[string]interface{}{
|
||||
"username": "alice",
|
||||
"custom": map[string]interface{}{
|
||||
"roles": []interface{}{"user"},
|
||||
},
|
||||
},
|
||||
Groups: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -105,7 +105,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
|
||||
// Build list of previewable item names in stable order (same as d.fileInfo.Files)
|
||||
var previewableNames []string
|
||||
for _, item := range d.fileInfo.Files {
|
||||
if !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
continue
|
||||
}
|
||||
previewableNames = append(previewableNames, item.Name)
|
||||
|
||||
+22
-139
@@ -13,7 +13,6 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
@@ -23,24 +22,9 @@ 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 {
|
||||
@@ -77,10 +61,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 "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction"
|
||||
// @Param skipExtendedAttrs query string false "Skip extended attributes for faster retrieval, no hasPreview"
|
||||
// @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 "When true, run audio/video metadata extraction, subtitles, and directory media batch processing"
|
||||
// @Param metadata query string false "Extract audio/video metadata if true"
|
||||
// @Param checksum query string false "Optional checksum validation"
|
||||
// @Success 200 {object} iteminfo.FileInfo "Resource metadata"
|
||||
// @Failure 404 {object} map[string]string "Resource not found"
|
||||
@@ -251,35 +235,25 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
|
||||
// Process each item one at a time
|
||||
for _, item := range items {
|
||||
rawPath := item.Path
|
||||
// Validate item
|
||||
if rawPath == "" {
|
||||
if item.Path == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: rawPath,
|
||||
Path: item.Path,
|
||||
Message: "path was empty",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Prevent deletion of root
|
||||
if rawPath == "/" {
|
||||
if item.Path == "/" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: rawPath,
|
||||
Path: item.Path,
|
||||
Message: "cannot delete root directory",
|
||||
})
|
||||
continue
|
||||
}
|
||||
sanitizedPath, err := utils.SanitizeUserPath(rawPath)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if d.share != nil {
|
||||
sourceName := d.share.GetSourceName()
|
||||
@@ -291,13 +265,13 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Path: item.Path,
|
||||
Message: "user does not have access",
|
||||
})
|
||||
continue
|
||||
}
|
||||
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
|
||||
indexPath := utils.JoinPathAsUnix(withoutUserScope, sanitizedPath)
|
||||
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
@@ -315,7 +289,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Path: item.Path,
|
||||
Message: "error deleting file/directory, admin must check the logs",
|
||||
})
|
||||
continue
|
||||
@@ -327,7 +301,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if item.Source == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Path: item.Path,
|
||||
Message: "source was empty, source is required",
|
||||
})
|
||||
continue
|
||||
@@ -338,7 +312,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Path: item.Path,
|
||||
Message: fmt.Sprintf("user does not have access: %v", err),
|
||||
})
|
||||
continue
|
||||
@@ -348,7 +322,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if idx == nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Path: item.Path,
|
||||
Message: "source not found",
|
||||
})
|
||||
continue
|
||||
@@ -357,14 +331,14 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// Get file info
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: sanitizedPath,
|
||||
Path: item.Path,
|
||||
Source: item.Source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, filePermUser, store.Share)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Path: item.Path,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
@@ -373,7 +347,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: sanitizedPath,
|
||||
Path: item.Path,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
@@ -393,74 +367,6 @@ 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.
|
||||
@@ -531,10 +437,6 @@ 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)
|
||||
@@ -604,7 +506,13 @@ 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))
|
||||
tempFilePath := fmt.Sprintf("%s.%s.uploading.tmp", realPath, hex.EncodeToString(hasher.Sum(nil)))
|
||||
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)
|
||||
}
|
||||
// Create or open the temporary file
|
||||
var outFile *os.File
|
||||
outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, fileutils.PermFile)
|
||||
@@ -626,31 +534,6 @@ 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
|
||||
|
||||
+8
-11
@@ -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,9 +143,6 @@ 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)
|
||||
|
||||
+34
-14
@@ -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 + "/icons/pwa-icon-512.png" // largest generated PWA icon
|
||||
banner := staticURL + "/pwa-icon-512.png" // Use largest generated icon for best quality
|
||||
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+"/icons/pwa-icon-512.png" {
|
||||
if banner == staticURL+"/pwa-icon-512.png" {
|
||||
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) but square works fine
|
||||
ogImage = staticURL + "/icons/pwa-icon-512.png"
|
||||
ogImage = staticURL + "/pwa-icon-512.png"
|
||||
}
|
||||
|
||||
// Construct the full URL for the current request
|
||||
@@ -158,9 +158,22 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
fullURL = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path)
|
||||
}
|
||||
|
||||
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
|
||||
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
|
||||
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
|
||||
// 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"
|
||||
}
|
||||
|
||||
data["htmlVars"] = map[string]interface{}{
|
||||
"title": title,
|
||||
@@ -173,8 +186,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"favicon": favicon,
|
||||
"loginIcon": loginIcon,
|
||||
"color": defaultThemeColor,
|
||||
"winIcon": staticURL + "/icons/mstile-256x256.png",
|
||||
"appIcon": staticURL + "/icons/apple-touch-icon.png",
|
||||
"winIcon": staticURL + "/mstile-256x256.png",
|
||||
"appIcon": staticURL + "/apple-touch-icon.png",
|
||||
"description": description,
|
||||
"loadingSpinnersCSS": template.CSS(loadingSpinnersCSS),
|
||||
"banner": banner,
|
||||
@@ -201,7 +214,6 @@ 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,
|
||||
@@ -300,7 +312,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.PWAIconsCacheDir(), "pwa-icon-512.png")
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
@@ -310,10 +322,18 @@ 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", "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)
|
||||
"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")
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
|
||||
+25
-46
@@ -3,10 +3,9 @@ package http
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
|
||||
@@ -22,15 +21,18 @@ 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) {
|
||||
err := checkPassword(r, d)
|
||||
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)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
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)
|
||||
url, err := auth.GenerateOtpForUser(targetUser, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
|
||||
}
|
||||
@@ -59,15 +61,21 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if code == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("code is required")
|
||||
}
|
||||
err := checkPassword(r, d)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
|
||||
err := auth.VerifyTotpCode(targetUser, code, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
@@ -78,32 +86,3 @@ 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
|
||||
}
|
||||
+4
-90
@@ -5,8 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -14,7 +12,6 @@ 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"
|
||||
@@ -180,14 +177,12 @@ 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. When the authenticated actor uses password login, they must send their current password in the X-Password header.
|
||||
// @Description Deletes a user identified by their ID.
|
||||
// @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]
|
||||
@@ -204,13 +199,8 @@ 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
|
||||
}
|
||||
@@ -219,15 +209,13 @@ 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. When the authenticated actor uses password login, they must send their current password in the X-Password header.
|
||||
// @Description Adds a new user to the system.
|
||||
// @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) {
|
||||
@@ -258,11 +246,6 @@ 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
|
||||
@@ -272,77 +255,16 @@ 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. 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.
|
||||
// @Description Updates the details of a user identified by ID.
|
||||
// @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]
|
||||
@@ -394,14 +316,6 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
if d.user.LoginMethod == users.LoginMethodPassword && !userPutOnlyNonAdminEditableFields(req.Which) {
|
||||
var status int
|
||||
status, err = verifyActorPasswordForUserActions(r, d)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
}
|
||||
|
||||
err = store.Users.Update(&req.User, d.user.Permissions.Admin, req.Which...)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
|
||||
@@ -24,8 +24,6 @@ func errToStatus(err error) int {
|
||||
return http.StatusForbidden
|
||||
case errors.Is(err, libErrors.ErrInvalidRequestParams):
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, libErrors.ErrIsDirectory):
|
||||
return http.StatusMethodNotAllowed
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
type iconSize struct {
|
||||
size int
|
||||
name string
|
||||
env *string
|
||||
}
|
||||
|
||||
// determineSourceType determines which type of favicon source we have
|
||||
@@ -152,13 +153,13 @@ func handleRasterFavicon(faviconPath string) ([]byte, error) {
|
||||
func getIconSizesToGenerate() []iconSize {
|
||||
return []iconSize{
|
||||
// Browser favicon (32x32 PNG for Safari/older browsers)
|
||||
{32, "favicon-32x32.png"},
|
||||
{32, "favicon-32x32.png", nil},
|
||||
// PWA icons (PNG required for Apple PWA compatibility)
|
||||
{192, "pwa-icon-192.png"},
|
||||
{256, "pwa-icon-256.png"}, // Also serves as Windows tile
|
||||
{512, "pwa-icon-512.png"},
|
||||
{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},
|
||||
// Platform-specific icons
|
||||
{180, "apple-touch-icon.png"}, // iOS home screen (required for Apple)
|
||||
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +181,10 @@ func generateIconSizes(sourceData []byte) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update environment variable if specified
|
||||
if icon.env != nil {
|
||||
*icon.env = filepath.Join("icons", icon.name)
|
||||
}
|
||||
generatedCount++
|
||||
}
|
||||
|
||||
@@ -194,7 +199,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.PWAIconsCacheDir(), icon.name)
|
||||
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputPath)
|
||||
@@ -228,11 +233,18 @@ 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...")
|
||||
cacheDir := settings.PWAIconsCacheDir()
|
||||
// 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"
|
||||
|
||||
// Create icons directory with configured permissions
|
||||
// Note: Parent cache directory should already exist from testCacheDirSpeed()
|
||||
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
|
||||
logger.Warningf("Failed to create PWA icons directory %s: %v", cacheDir, err)
|
||||
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)
|
||||
}
|
||||
// Load and prepare source data as PNG
|
||||
sourceData, err := loadFaviconSource()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
|
||||
@@ -33,9 +36,6 @@ 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,9 +68,7 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// InitializePWAManifest generates and caches the PWA manifest at startup
|
||||
func InitializePWAManifest() {
|
||||
config := &settings.Config
|
||||
staticURL := config.Server.BaseURL + "public/static"
|
||||
@@ -78,9 +76,21 @@ func InitializePWAManifest() {
|
||||
description := config.Frontend.Description
|
||||
defaultThemeColor := "#455a64"
|
||||
|
||||
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
|
||||
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
|
||||
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
|
||||
// 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"
|
||||
}
|
||||
|
||||
CachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
|
||||
}
|
||||
@@ -16,26 +16,44 @@ func TestFetchExtendedAttributes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
adjustedPath string
|
||||
combinedPath string
|
||||
files []os.FileInfo
|
||||
opts Options
|
||||
expectHasPreview bool
|
||||
expectMapNotEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "SkipExtendedAttrs returns empty",
|
||||
adjustedPath: "/test/",
|
||||
opts: Options{SkipExtendedAttrs: true, Recursive: false},
|
||||
name: "SkipExtendedAttrs returns empty",
|
||||
adjustedPath: "/test/",
|
||||
combinedPath: "/test/",
|
||||
files: []os.FileInfo{},
|
||||
opts: Options{
|
||||
SkipExtendedAttrs: true,
|
||||
Recursive: false,
|
||||
},
|
||||
expectHasPreview: false,
|
||||
expectMapNotEmpty: false,
|
||||
},
|
||||
{
|
||||
name: "Recursive returns empty",
|
||||
adjustedPath: "/test/",
|
||||
opts: Options{SkipExtendedAttrs: false, Recursive: true},
|
||||
name: "Recursive returns empty",
|
||||
adjustedPath: "/test/",
|
||||
combinedPath: "/test/",
|
||||
files: []os.FileInfo{},
|
||||
opts: Options{
|
||||
SkipExtendedAttrs: false,
|
||||
Recursive: true,
|
||||
},
|
||||
expectHasPreview: false,
|
||||
expectMapNotEmpty: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.opts)
|
||||
hasPreview, subdirMap := idx.fetchExtendedAttributes(tt.adjustedPath, tt.files, tt.opts)
|
||||
if hasPreview != tt.expectHasPreview {
|
||||
t.Errorf("expected hasPreview=%v, got %v", tt.expectHasPreview, hasPreview)
|
||||
}
|
||||
if (len(subdirMap) > 0) != tt.expectMapNotEmpty {
|
||||
t.Errorf("expected mapNotEmpty=%v, got %v", tt.expectMapNotEmpty, len(subdirMap) > 0)
|
||||
}
|
||||
@@ -43,28 +61,6 @@ func TestFetchExtendedAttributes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchExtendedAttributes_subdirHasPreviewFromMetadata checks folder hasPreview comes from one GetMetadataInfo (non-shallow).
|
||||
func TestFetchExtendedAttributes_subdirHasPreviewFromMetadata(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
deepdir, err := idx.db.GetItem("test", "/subdir/deepdir/")
|
||||
if err != nil || deepdir == nil {
|
||||
t.Fatalf("deepdir row: err=%v", err)
|
||||
}
|
||||
deepdir.HasPreview = true
|
||||
_ = idx.db.InsertItem("test", "/subdir/deepdir/", deepdir)
|
||||
|
||||
subdirMap := idx.fetchExtendedAttributes("/subdir/", Options{
|
||||
SkipExtendedAttrs: false,
|
||||
Recursive: false,
|
||||
})
|
||||
|
||||
if !subdirMap["/subdir/deepdir/"] {
|
||||
t.Errorf("expected hasPreview map for /subdir/deepdir/, got %v", subdirMap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDirectoryName tests the directory name extraction logic
|
||||
func TestGetDirectoryName(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -214,6 +210,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -85,17 +85,17 @@ type Stats struct {
|
||||
LastIndexedUnix int64 `json:"lastIndexedUnixTime"`
|
||||
Complexity uint `json:"complexity"`
|
||||
LastScanned time.Time `json:"lastScanned"`
|
||||
UsedAsIndexed uint64 `json:"used"`
|
||||
UsedDisk uint64 `json:"usedAlt"`
|
||||
DiskTotal uint64 `json:"total"`
|
||||
DiskUsed uint64 `json:"used"`
|
||||
UsedAlt uint64 `json:"usedAlt"`
|
||||
}
|
||||
|
||||
// reduced index is json exposed to the client
|
||||
type ReducedIndex struct {
|
||||
Stats
|
||||
IdxName string `json:"name"`
|
||||
Status IndexStatus `json:"status"`
|
||||
Scanners []*ScannerInfo `json:"scanners,omitempty"`
|
||||
IdxName string `json:"name"`
|
||||
DiskTotal uint64 `json:"total"`
|
||||
Status IndexStatus `json:"status"`
|
||||
Scanners []*ScannerInfo `json:"scanners,omitempty"`
|
||||
}
|
||||
|
||||
type Index struct {
|
||||
@@ -734,26 +734,50 @@ func (idx *Index) GetFsInfoViewableOnly(adjustedPath string, followSymlinks bool
|
||||
})
|
||||
}
|
||||
|
||||
// fetchExtendedAttributes loads indexed subfolder has_preview for the current directory from GetMetadataInfo (database).
|
||||
// The listed directory's own HasPreview is derived only in GetDirInfoCore from children (never from a stale parent DB row).
|
||||
func (idx *Index) fetchExtendedAttributes(indexPath string, opts Options) map[string]bool {
|
||||
// fetchExtendedAttributes fetches hasPreview for the current directory and batch fetches for subdirectories
|
||||
func (idx *Index) fetchExtendedAttributes(indexPath string, files []os.FileInfo, opts Options) (bool, map[string]bool) {
|
||||
hasPreview := false
|
||||
subdirHasPreviewMap := make(map[string]bool)
|
||||
|
||||
if opts.SkipExtendedAttrs || opts.Recursive {
|
||||
return subdirHasPreviewMap
|
||||
return hasPreview, subdirHasPreviewMap
|
||||
}
|
||||
|
||||
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, false)
|
||||
if !exists {
|
||||
return subdirHasPreviewMap
|
||||
// Fetch hasPreview for current directory
|
||||
realDirInfo, exists := idx.GetMetadataInfo(indexPath, true, true)
|
||||
if exists {
|
||||
hasPreview = realDirInfo.HasPreview
|
||||
}
|
||||
|
||||
for _, folder := range realDirInfo.Folders {
|
||||
key := utils.AddTrailingSlashIfNotExists(indexPath + folder.Name)
|
||||
subdirHasPreviewMap[key] = folder.HasPreview
|
||||
// Batch fetch hasPreview for all subdirectories
|
||||
var subdirPaths []string
|
||||
for _, file := range files {
|
||||
if !iteminfo.IsDirectory(file) {
|
||||
continue
|
||||
}
|
||||
baseName := file.Name()
|
||||
if indexPath == "/" {
|
||||
if !idx.shouldInclude(baseName) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if omitList[baseName] {
|
||||
continue
|
||||
}
|
||||
dirPath := indexPath + baseName
|
||||
childIndexPath := utils.AddTrailingSlashIfNotExists(dirPath)
|
||||
subdirPaths = append(subdirPaths, childIndexPath)
|
||||
}
|
||||
|
||||
return subdirHasPreviewMap
|
||||
if len(subdirPaths) > 0 {
|
||||
var err error
|
||||
subdirHasPreviewMap, err = idx.db.GetHasPreviewBatch(idx.Name, subdirPaths)
|
||||
if err != nil {
|
||||
subdirHasPreviewMap = make(map[string]bool)
|
||||
}
|
||||
}
|
||||
|
||||
return hasPreview, subdirHasPreviewMap
|
||||
}
|
||||
|
||||
// processDirectoryItem processes a directory item and returns the itemInfo, size, and whether it should be counted
|
||||
@@ -801,7 +825,11 @@ func (idx *Index) processDirectoryItem(file os.FileInfo, indexPath string, subdi
|
||||
if !opts.SkipExtendedAttrs && subdirHasPreviewMap != nil {
|
||||
if hasPreviewValue, exists := subdirHasPreviewMap[childIndexPath]; exists {
|
||||
itemInfo.HasPreview = hasPreviewValue
|
||||
} else {
|
||||
itemInfo.HasPreview = false
|
||||
}
|
||||
} else {
|
||||
itemInfo.HasPreview = false
|
||||
}
|
||||
|
||||
return itemInfo, itemInfo.Size, true
|
||||
@@ -847,7 +875,9 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
|
||||
if !usedCachedPreview {
|
||||
setFilePreviewFlags(itemInfo, fullCombined)
|
||||
}
|
||||
bubblesUpHasPreview = iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo)
|
||||
if itemInfo.HasPreview && iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo) {
|
||||
bubblesUpHasPreview = true
|
||||
}
|
||||
} else {
|
||||
itemInfo.HasPreview = false
|
||||
}
|
||||
@@ -864,7 +894,8 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, opts)
|
||||
// Fetch extended attributes (hasPreview for current dir and subdirs)
|
||||
hasPreview, subdirHasPreviewMap := idx.fetchExtendedAttributes(indexPath, files, opts)
|
||||
|
||||
baseName := filepath.Base(indexPath)
|
||||
if indexPath == "/" {
|
||||
@@ -875,7 +906,6 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
fileInfos := []iteminfo.ExtendedItemInfo{}
|
||||
dirInfos := []iteminfo.ItemInfo{}
|
||||
processedCount := 0
|
||||
hasPreview := false
|
||||
|
||||
for _, file := range files {
|
||||
subfileBaseName := file.Name()
|
||||
@@ -930,12 +960,12 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
|
||||
}
|
||||
} else {
|
||||
itemInfo, size, shouldCount, bubblesUp := idx.processFileItem(file, indexPath, opts, scanner)
|
||||
if bubblesUp {
|
||||
hasPreview = true
|
||||
}
|
||||
if shouldCount {
|
||||
totalSize += size
|
||||
}
|
||||
if bubblesUp {
|
||||
hasPreview = true
|
||||
}
|
||||
fileInfos = append(fileInfos, iteminfo.ExtendedItemInfo{ItemInfo: *itemInfo})
|
||||
if opts.IsRoutineScan {
|
||||
idx.incrementScannerFiles()
|
||||
@@ -1102,7 +1132,6 @@ func (idx *Index) updateFolderSizeAndParents(path string, newSize uint64, previo
|
||||
if parentDir == "" {
|
||||
break // Reached the top
|
||||
}
|
||||
// Keys in folderSizes match indexing paths (trailing slash for non-root dirs; root stays "/").
|
||||
if parentDir != "/" {
|
||||
parentDir = utils.AddTrailingSlashIfNotExists(parentDir)
|
||||
}
|
||||
@@ -1129,6 +1158,22 @@ 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)
|
||||
@@ -1159,6 +1204,20 @@ 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 {
|
||||
@@ -1473,15 +1532,18 @@ func (idx *Index) IsNeverWatchPath(adjustedPath string) bool {
|
||||
return exists
|
||||
}
|
||||
|
||||
func (idx *Index) SetUsage(totalDiskSize, partitionUsed, UsedAsIndexed uint64) {
|
||||
type DiskUsage struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
}
|
||||
|
||||
func (idx *Index) SetUsage(totalBytes uint64) {
|
||||
if settings.Config.Frontend.DisableUsedPercentage {
|
||||
return
|
||||
}
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
idx.DiskTotal = totalDiskSize
|
||||
idx.UsedAsIndexed = UsedAsIndexed
|
||||
idx.UsedDisk = partitionUsed
|
||||
idx.DiskTotal = totalBytes
|
||||
}
|
||||
|
||||
func (idx *Index) SetStatus(status IndexStatus) error {
|
||||
@@ -1566,13 +1628,14 @@ func (idx *Index) shouldInclude(baseName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// writePersistedIndexInfo writes scanners, stats, and disk totals to Bolt (no SSE broadcast).
|
||||
func (idx *Index) writePersistedIndexInfo() error {
|
||||
// Save persists the index and scanner information to the database
|
||||
func (idx *Index) Save() error {
|
||||
if indexingStorage == nil {
|
||||
return nil
|
||||
return nil // No storage available, skip persistence
|
||||
}
|
||||
|
||||
idx.mu.RLock()
|
||||
// Collect scanner information
|
||||
scanners := make(map[string]*dbindex.PersistedScannerInfo)
|
||||
for path, scanner := range idx.scanners {
|
||||
scanners[path] = &dbindex.PersistedScannerInfo{
|
||||
@@ -1587,37 +1650,32 @@ func (idx *Index) writePersistedIndexInfo() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Get current index stats
|
||||
complexity := idx.getComplexityUnlocked()
|
||||
numDirs := idx.getNumDirsUnlocked()
|
||||
numFiles := idx.getNumFilesUnlocked()
|
||||
usedAsIndexed := idx.UsedAsIndexed
|
||||
usedDisk := idx.UsedDisk
|
||||
diskTotal := idx.DiskTotal
|
||||
idx.mu.RUnlock()
|
||||
|
||||
// Create IndexInfo for persistence
|
||||
info := &dbindex.IndexInfo{
|
||||
Path: idx.Path,
|
||||
Source: idx.Name,
|
||||
Complexity: complexity,
|
||||
NumDirs: numDirs,
|
||||
NumFiles: numFiles,
|
||||
UsedAsIndexed: usedAsIndexed,
|
||||
UsedDisk: usedDisk,
|
||||
DiskTotal: diskTotal,
|
||||
Scanners: scanners,
|
||||
Path: idx.Path, // Use real filesystem path as key
|
||||
Source: idx.Name,
|
||||
Complexity: complexity,
|
||||
NumDirs: numDirs,
|
||||
NumFiles: numFiles,
|
||||
Scanners: scanners,
|
||||
}
|
||||
|
||||
return indexingStorage.Save(info)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
err := indexingStorage.Save(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send SSE update event after successful save
|
||||
if err := idx.SendSourceUpdateEvent(); err != nil {
|
||||
logger.Errorf("[%s] Failed to send source update event: %v", idx.Name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1645,9 +1703,6 @@ func (idx *Index) Load() error {
|
||||
idx.Complexity = info.Complexity
|
||||
idx.NumDirs = info.NumDirs
|
||||
idx.NumFiles = info.NumFiles
|
||||
idx.UsedAsIndexed = info.UsedAsIndexed
|
||||
idx.UsedDisk = info.UsedDisk
|
||||
idx.DiskTotal = info.DiskTotal
|
||||
|
||||
// Restore scanner information (will be applied when scanners are created)
|
||||
// Store in a temporary map that setupMultiScanner can use
|
||||
|
||||
@@ -502,6 +502,7 @@ 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
|
||||
@@ -538,10 +539,7 @@ func (s *Scanner) updateSchedule() {
|
||||
s.currentSchedule = len(scanSchedule) - 1
|
||||
}
|
||||
|
||||
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).
|
||||
// Persist index after schedule update (happens after each scan)
|
||||
if err := s.idx.Save(); err != nil {
|
||||
logger.Errorf("Failed to save index after schedule update: %v", err)
|
||||
}
|
||||
@@ -551,6 +549,8 @@ 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,9 +559,7 @@ func (s *Scanner) updateComplexity() {
|
||||
} else {
|
||||
s.smartModifier = 0
|
||||
}
|
||||
s.statsMu.Unlock()
|
||||
|
||||
// Persist after full scans — release statsMu before Save (same ordering as updateSchedule).
|
||||
// Persist index after complexity update (happens after full scans)
|
||||
if err := s.idx.Save(); err != nil {
|
||||
logger.Errorf("Failed to save index after complexity update: %v", err)
|
||||
}
|
||||
|
||||
@@ -205,9 +205,7 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
if idx.mock {
|
||||
return nil
|
||||
}
|
||||
// Avoid force=true: otherwise GetIndexInfo invalidates DiskUsageCache, recomputes, and writePersistedIndexInfo
|
||||
// nests under Save recursion; false uses cached probe / current idx totals for the broadcast payload.
|
||||
reducedIndex, err := GetIndexInfo(idx.Name, false)
|
||||
reducedIndex, err := GetIndexInfo(idx.Name, true)
|
||||
if err != nil {
|
||||
logger.Errorf("[%s] Error getting index info: %v", idx.Name, err)
|
||||
return err
|
||||
@@ -228,12 +226,21 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
// setupMultiScanner creates and starts the multi-scanner system
|
||||
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
|
||||
func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
// Load persisted index-level stats from Bolt (complexity, numDirs/Files, used/usedAlt/total, etc.)
|
||||
// Scanner-level stats below use a second GetByPath only for the Scanners map.
|
||||
// Load persisted index and scanner information
|
||||
if err := idx.Load(); err != nil {
|
||||
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -282,7 +282,6 @@ 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",
|
||||
|
||||
@@ -30,8 +30,6 @@ 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
|
||||
@@ -58,5 +56,6 @@ 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:"-"`
|
||||
}
|
||||
@@ -405,9 +405,6 @@ 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
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package iteminfo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShouldBubbleUpToFolderPreview(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item ItemInfo
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "directory type never bubbles",
|
||||
item: ItemInfo{Name: "sub", Type: "directory"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "text markdown does not bubble",
|
||||
item: ItemInfo{Name: "README.md", Type: "text/markdown"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "plain text does not bubble",
|
||||
item: ItemInfo{Name: "LICENSE", Type: "text/plain; charset=utf-8"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "image does not bubble without HasPreview",
|
||||
item: ItemInfo{Name: "a.png", Type: "image/png", HasPreview: false},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "image bubbles when HasPreview",
|
||||
item: ItemInfo{Name: "a.png", Type: "image/png", HasPreview: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "video bubbles when HasPreview",
|
||||
item: ItemInfo{Name: "clip.mp4", Type: "video/mp4", HasPreview: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "audio bubbles when HasPreview (e.g. album art)",
|
||||
item: ItemInfo{Name: "track.mp3", Type: "audio/mpeg", HasPreview: true},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OnlyOffice-backed extension does not bubble",
|
||||
item: ItemInfo{Name: "report.docx", Type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unknown blob type does not bubble",
|
||||
item: ItemInfo{Name: "data.bin", Type: "application/octet-stream"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty type does not bubble",
|
||||
item: ItemInfo{Name: "weird", Type: ""},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ShouldBubbleUpToFolderPreview(tt.item); got != tt.want {
|
||||
t.Fatalf("ShouldBubbleUpToFolderPreview() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,6 @@ 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)
|
||||
|
||||
@@ -36,29 +34,25 @@ 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)
|
||||
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
|
||||
fileBase := strings.TrimSuffix(f.Name, fileExt)
|
||||
|
||||
// Check if this file has the same base name and a subtitle extension
|
||||
if matches && SubtitleExts[strings.ToLower(fileExt)] {
|
||||
if fileBase == baseWithoutExt && 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(fileBaseWithoutExt, ".")
|
||||
parts := strings.Split(fileBase, ".")
|
||||
if len(parts) > 1 {
|
||||
lastPart := parts[len(parts)-1]
|
||||
if len(lastPart) == 2 || len(lastPart) == 3 {
|
||||
track.Language = lastPart
|
||||
}
|
||||
}
|
||||
|
||||
externalSubs = append(externalSubs, track)
|
||||
}
|
||||
}
|
||||
|
||||
+34
-58
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
|
||||
// scanner parameter is optional - if nil (API refresh), directly inserts to database
|
||||
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,9 +296,8 @@ 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.
|
||||
// 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.
|
||||
// Only update disk total if cache is missing or explicitly forced
|
||||
// The "used" value comes from totalSize and is always current
|
||||
sourcePath := idx.Path
|
||||
cacheKey := "usageCache-" + sourceName
|
||||
if forceCacheRefresh {
|
||||
@@ -308,40 +307,27 @@ 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)
|
||||
totalPartitionSize, err := fileutils.GetPartitionSize(sourcePath)
|
||||
totalBytes, err := fileutils.GetPartitionSize(sourcePath)
|
||||
if err != nil {
|
||||
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.mu.Lock()
|
||||
idx.Status = UNAVAILABLE
|
||||
idx.mu.Unlock()
|
||||
return ReducedIndex{}, fmt.Errorf("error getting disk usage for %s: %v", sourcePath, err)
|
||||
}
|
||||
|
||||
idx.SetUsage(totalBytes)
|
||||
|
||||
// Also fetch OS-reported partition used space (total - free)
|
||||
partitionUsed, err := fileutils.GetPartitionUsed(sourcePath)
|
||||
usedAlt, err := fileutils.GetPartitionUsed(sourcePath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get partition used space for index %s: %v", sourceName, err)
|
||||
usedAlt = 0
|
||||
}
|
||||
idx.SetUsage(totalPartitionSize, partitionUsed, indexedSizeFromDB)
|
||||
idx.mu.Lock()
|
||||
idx.UsedAlt = usedAlt
|
||||
idx.mu.Unlock()
|
||||
|
||||
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
|
||||
@@ -351,19 +337,25 @@ 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
|
||||
reducedIdx.UsedAsIndexed = idx.UsedAsIndexed
|
||||
reducedIdx.UsedDisk = idx.UsedDisk
|
||||
// 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.DiskTotal = idx.DiskTotal
|
||||
reducedIdx.Scanners = scannerInfos
|
||||
reducedIdx.NumDirs = idx.getNumDirsUnlocked()
|
||||
@@ -378,19 +370,3 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
idx.mu.RUnlock()
|
||||
return reducedIdx, nil
|
||||
}
|
||||
|
||||
// SetFolderSize sets the size for a directory in the in-memory map
|
||||
func (idx *Index) SetFolderSize(path string, newSize uint64) {
|
||||
idx.folderSizesMu.Lock()
|
||||
defer idx.folderSizesMu.Unlock()
|
||||
idx.folderSizes[path] = newSize
|
||||
idx.folderSizesUnsynced[path] = struct{}{} // Mark as changed for next DB sync
|
||||
}
|
||||
|
||||
// GetFolderSize retrieves the size for a directory from the in-memory map
|
||||
func (idx *Index) GetFolderSize(path string) (uint64, bool) {
|
||||
idx.folderSizesMu.RLock()
|
||||
defer idx.folderSizesMu.RUnlock()
|
||||
size, exists := idx.folderSizes[path]
|
||||
return size, exists
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
.card,
|
||||
.listing-item,
|
||||
.floating-window,
|
||||
#listingView.gallery .item,
|
||||
#listingView.normal .item,
|
||||
.button,
|
||||
button,
|
||||
.input,
|
||||
|
||||
@@ -1074,69 +1074,6 @@ 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",
|
||||
@@ -1447,7 +1384,7 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction",
|
||||
"description": "Skip extended attributes for faster retrieval, no hasPreview",
|
||||
"name": "skipExtendedAttrs",
|
||||
"in": "query"
|
||||
},
|
||||
@@ -1466,7 +1403,7 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "When true, run audio/video metadata extraction, subtitles, and directory media batch processing",
|
||||
"description": "Extract audio/video metadata if true",
|
||||
"name": "metadata",
|
||||
"in": "query"
|
||||
},
|
||||
@@ -2092,69 +2029,6 @@ 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.",
|
||||
@@ -3076,7 +2950,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"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.",
|
||||
"description": "Updates the details of a user identified by ID.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3097,12 +2971,6 @@ 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",
|
||||
@@ -3126,15 +2994,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
@@ -3156,7 +3015,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"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.",
|
||||
"description": "Adds a new user to the system.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3168,12 +3027,6 @@ 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",
|
||||
@@ -3200,15 +3053,6 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
@@ -3221,7 +3065,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"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.",
|
||||
"description": "Deletes a user identified by their ID.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3239,27 +3083,12 @@ 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": {
|
||||
@@ -3301,46 +3130,6 @@ 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.",
|
||||
@@ -3951,60 +3740,6 @@ 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.",
|
||||
@@ -4706,99 +4441,6 @@ 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": {
|
||||
@@ -5220,15 +4862,6 @@ 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"
|
||||
@@ -5282,7 +4915,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)",
|
||||
"description": "secret: shared secret key for verifying JWT token signatures (required)",
|
||||
"type": "string"
|
||||
},
|
||||
"userGroups": {
|
||||
@@ -6879,10 +6512,6 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/users.SourceScope"
|
||||
}
|
||||
},
|
||||
"showCopyPath": {
|
||||
"description": "show copy path action in the context menu",
|
||||
"type": "boolean"
|
||||
},
|
||||
"showFirstLogin": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -6939,35 +6568,6 @@ 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -1063,69 +1063,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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",
|
||||
@@ -1436,7 +1373,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction",
|
||||
"description": "Skip extended attributes for faster retrieval, no hasPreview",
|
||||
"name": "skipExtendedAttrs",
|
||||
"in": "query"
|
||||
},
|
||||
@@ -1455,7 +1392,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "When true, run audio/video metadata extraction, subtitles, and directory media batch processing",
|
||||
"description": "Extract audio/video metadata if true",
|
||||
"name": "metadata",
|
||||
"in": "query"
|
||||
},
|
||||
@@ -2081,69 +2018,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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.",
|
||||
@@ -3065,7 +2939,7 @@
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"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.",
|
||||
"description": "Updates the details of a user identified by ID.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3086,12 +2960,6 @@
|
||||
"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",
|
||||
@@ -3115,15 +2983,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
@@ -3145,7 +3004,7 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"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.",
|
||||
"description": "Adds a new user to the system.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3157,12 +3016,6 @@
|
||||
],
|
||||
"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",
|
||||
@@ -3189,15 +3042,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing actor password when required",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
@@ -3210,7 +3054,7 @@
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"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.",
|
||||
"description": "Deletes a user identified by their ID.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -3228,27 +3072,12 @@
|
||||
"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": {
|
||||
@@ -3290,46 +3119,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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.",
|
||||
@@ -3940,60 +3729,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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.",
|
||||
@@ -4695,99 +4430,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -5209,15 +4851,6 @@
|
||||
"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"
|
||||
@@ -5271,7 +4904,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"secret": {
|
||||
"description": "Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)",
|
||||
"description": "secret: shared secret key for verifying JWT token signatures (required)",
|
||||
"type": "string"
|
||||
},
|
||||
"userGroups": {
|
||||
@@ -6868,10 +6501,6 @@
|
||||
"$ref": "#/definitions/users.SourceScope"
|
||||
}
|
||||
},
|
||||
"showCopyPath": {
|
||||
"description": "show copy path action in the context menu",
|
||||
"type": "boolean"
|
||||
},
|
||||
"showFirstLogin": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -6928,35 +6557,6 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,72 +359,6 @@ 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:
|
||||
@@ -751,14 +685,6 @@ 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.
|
||||
@@ -801,8 +727,8 @@ definitions:
|
||||
to logout url. Custom logout query params are respected.
|
||||
type: string
|
||||
secret:
|
||||
description: Shared secret key/bytes for verifying JWT token signatures (required,
|
||||
eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
|
||||
description: 'secret: shared secret key for verifying JWT token signatures
|
||||
(required)'
|
||||
type: string
|
||||
userGroups:
|
||||
description: if set, only users in these groups are allowed to log in. Blocks
|
||||
@@ -1984,9 +1910,6 @@ definitions:
|
||||
items:
|
||||
$ref: '#/definitions/users.SourceScope'
|
||||
type: array
|
||||
showCopyPath:
|
||||
description: show copy path action in the context menu
|
||||
type: boolean
|
||||
showFirstLogin:
|
||||
type: boolean
|
||||
showHidden:
|
||||
@@ -2030,27 +1953,6 @@ 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:
|
||||
@@ -2768,49 +2670,6 @@ 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:
|
||||
@@ -3056,8 +2915,7 @@ paths:
|
||||
name: path
|
||||
required: true
|
||||
type: string
|
||||
- description: When true, omit index-level extended fields (e.g. hasPreview);
|
||||
does not disable ffmpeg/media extraction
|
||||
- description: Skip extended attributes for faster retrieval, no hasPreview
|
||||
in: query
|
||||
name: skipExtendedAttrs
|
||||
type: string
|
||||
@@ -3070,8 +2928,7 @@ paths:
|
||||
in: query
|
||||
name: content
|
||||
type: string
|
||||
- description: When true, run audio/video metadata extraction, subtitles, and
|
||||
directory media batch processing
|
||||
- description: Extract audio/video metadata if true
|
||||
in: query
|
||||
name: metadata
|
||||
type: string
|
||||
@@ -3482,50 +3339,6 @@ 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:
|
||||
@@ -4136,31 +3949,18 @@ paths:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
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.
|
||||
description: Deletes a user identified by their ID.
|
||||
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:
|
||||
@@ -4217,14 +4017,8 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
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.
|
||||
description: Adds a new user to the system.
|
||||
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
|
||||
@@ -4244,12 +4038,6 @@ 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:
|
||||
@@ -4262,11 +4050,7 @@ paths:
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
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.
|
||||
description: Updates the details of a user identified by ID.
|
||||
parameters:
|
||||
- description: user ID to update
|
||||
in: query
|
||||
@@ -4276,11 +4060,6 @@ 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
|
||||
@@ -4296,12 +4075,6 @@ 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:
|
||||
@@ -4330,33 +4103,6 @@ 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:
|
||||
@@ -4782,43 +4528,6 @@ 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:
|
||||
|
||||
Generated
+308
-240
@@ -11,6 +11,7 @@
|
||||
"@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",
|
||||
@@ -35,13 +36,12 @@
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"deepl-node": "^1.16.0",
|
||||
"deepl-node": "^1.18.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.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -1165,9 +1165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
|
||||
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.1.tgz",
|
||||
"integrity": "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1179,9 +1179,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.1.tgz",
|
||||
"integrity": "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1193,9 +1193,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.1.tgz",
|
||||
"integrity": "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1207,9 +1207,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
|
||||
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.1.tgz",
|
||||
"integrity": "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1221,9 +1221,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1235,9 +1235,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
|
||||
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.1.tgz",
|
||||
"integrity": "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1249,9 +1249,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
|
||||
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1263,9 +1263,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
|
||||
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.1.tgz",
|
||||
"integrity": "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1277,9 +1277,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1291,9 +1291,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1305,9 +1305,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1319,9 +1319,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1333,9 +1333,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1347,9 +1347,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.1.tgz",
|
||||
"integrity": "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1361,9 +1361,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1375,9 +1375,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1389,9 +1389,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1403,9 +1403,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1417,9 +1417,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
|
||||
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1431,9 +1431,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
|
||||
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.1.tgz",
|
||||
"integrity": "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1445,9 +1445,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
|
||||
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1459,9 +1459,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
|
||||
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.1.tgz",
|
||||
"integrity": "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1473,9 +1473,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
|
||||
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1487,9 +1487,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
|
||||
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1501,9 +1501,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
|
||||
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
|
||||
"version": "4.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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1550,9 +1550,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"version": "24.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1959,53 +1959,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
|
||||
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
|
||||
"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.2",
|
||||
"@vue/shared": "3.5.33",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/shared": "3.5.30",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
|
||||
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
|
||||
"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
"@vue/compiler-core": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
|
||||
"integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
|
||||
"integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.2",
|
||||
"@vue/compiler-core": "3.5.33",
|
||||
"@vue/compiler-dom": "3.5.33",
|
||||
"@vue/compiler-ssr": "3.5.33",
|
||||
"@vue/shared": "3.5.33",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@vue/compiler-core": "3.5.30",
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-ssr": "3.5.30",
|
||||
"@vue/shared": "3.5.30",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.10",
|
||||
"postcss": "^8.5.8",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
|
||||
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
|
||||
"integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-vue2": {
|
||||
@@ -2076,59 +2076,60 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
|
||||
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
|
||||
"integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.33"
|
||||
"@vue/shared": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
|
||||
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
|
||||
"integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
"@vue/reactivity": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
|
||||
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
|
||||
"integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.33",
|
||||
"@vue/runtime-core": "3.5.33",
|
||||
"@vue/shared": "3.5.33",
|
||||
"@vue/reactivity": "3.5.30",
|
||||
"@vue/runtime-core": "3.5.30",
|
||||
"@vue/shared": "3.5.30",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
|
||||
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
|
||||
"integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
"@vue/compiler-ssr": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.33"
|
||||
"vue": "3.5.30"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
|
||||
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
|
||||
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
|
||||
"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.*",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -2163,6 +2164,16 @@
|
||||
"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",
|
||||
@@ -2174,9 +2185,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2257,14 +2268,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
|
||||
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@@ -2307,9 +2318,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -2433,6 +2444,17 @@
|
||||
"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",
|
||||
@@ -2637,16 +2659,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepl-node": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.16.0.tgz",
|
||||
"integrity": "sha512-sYrh8UngVHKBrgffLPl+ng065mlC+Ep3LBdSN3xqDBd4KdBZzgvdxHiHd9Pm9xQCI5pwyuCHyq0cDUW1T3vQUg==",
|
||||
"version": "1.24.0",
|
||||
"resolved": "https://registry.npmjs.org/deepl-node/-/deepl-node-1.24.0.tgz",
|
||||
"integrity": "sha512-vZ9jUpzJRvFamgVOfm1LDy3YYJ7k8FhxtAX9whR92EFshLIP9JlYS0HFwXL5yYsfqzXdb/wssGRSWvR48t7nSg==",
|
||||
"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"
|
||||
"loglevel": ">=1.6.2",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
@@ -2678,6 +2702,12 @@
|
||||
"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",
|
||||
@@ -2711,9 +2741,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz",
|
||||
"integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -3463,9 +3493,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"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==",
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -3679,6 +3709,15 @@
|
||||
"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",
|
||||
@@ -3743,9 +3782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -4130,9 +4169,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4228,9 +4267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -4311,6 +4350,15 @@
|
||||
"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",
|
||||
@@ -4372,9 +4420,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4406,12 +4454,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -4769,9 +4817,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4801,13 +4849,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -4820,9 +4868,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -4846,9 +4894,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -4904,13 +4952,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"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"
|
||||
}
|
||||
"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"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -4923,9 +4968,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.vue": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.8.1.tgz",
|
||||
"integrity": "sha512-kJORXcI5Uml5GtzcfFkchpljFgaXKQB/3ZUt+Hwnw7Y1F7FiA01U2yyO5Vscwv3bVxPkm/KLKXUOXsHbTsYrTw==",
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.8.0.tgz",
|
||||
"integrity": "sha512-+XKbSKvQu158zlHsJm+HObGUQM3Q+9Oq6yb2op/6lMM2gwOiiG9uIhujvrSV5UoTjrzR0BcNxCdW2kk7KE4NEg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
@@ -5048,9 +5093,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
|
||||
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
|
||||
"version": "4.59.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.1.tgz",
|
||||
"integrity": "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5064,31 +5109,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.2",
|
||||
"@rollup/rollup-android-arm64": "4.60.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.2",
|
||||
"@rollup/rollup-darwin-x64": "4.60.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.2",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.2",
|
||||
"@rollup/rollup-android-arm-eabi": "4.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",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -5149,6 +5194,12 @@
|
||||
"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",
|
||||
@@ -5462,6 +5513,12 @@
|
||||
"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",
|
||||
@@ -5484,14 +5541,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -5642,11 +5699,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -5734,10 +5792,20 @@
|
||||
"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.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6047,16 +6115,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.33",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
|
||||
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
|
||||
"version": "3.5.30",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.33",
|
||||
"@vue/compiler-sfc": "3.5.33",
|
||||
"@vue/runtime-dom": "3.5.33",
|
||||
"@vue/server-renderer": "3.5.33",
|
||||
"@vue/shared": "3.5.33"
|
||||
"@vue/compiler-dom": "3.5.30",
|
||||
"@vue/compiler-sfc": "3.5.30",
|
||||
"@vue/runtime-dom": "3.5.30",
|
||||
"@vue/server-renderer": "3.5.30",
|
||||
"@vue/shared": "3.5.30"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
"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 "
|
||||
},
|
||||
@@ -25,6 +23,7 @@
|
||||
"@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",
|
||||
@@ -43,8 +42,7 @@
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.1",
|
||||
"@xmldom/xmldom": "^0.8.12"
|
||||
"minimatch": "^10.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
@@ -53,13 +51,12 @@
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"deepl-node": "^1.16.0",
|
||||
"deepl-node": "^1.18.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",
|
||||
|
||||
@@ -44,8 +44,7 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
/* Include every spec under testDir (prompts.spec.ts, settings-screenshots, etc.) */
|
||||
testMatch: /\.spec\.ts$/,
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
@@ -54,7 +53,7 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /\.spec\.ts$/,
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -58,7 +58,6 @@ 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.
|
||||
@@ -116,7 +115,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: "" # Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
|
||||
secret: "" # secret: shared secret key for verifying JWT token signatures (required)
|
||||
algorithm: "" # JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
|
||||
key: "" # secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
|
||||
adminUsername: "admin" # secret: the username of the admin user. If not set, the default is "admin".
|
||||
|
||||
@@ -347,14 +347,3 @@ 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.
Binary file not shown.
@@ -13,7 +13,8 @@
|
||||
<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-symbols.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>
|
||||
|
||||
<!-- Add to home screen for Android and modern mobile browsers -->
|
||||
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
|
||||
|
||||
@@ -8,8 +8,6 @@ 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);
|
||||
@@ -21,14 +19,13 @@ 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 (requireApiKey && !DEEPL_API_KEY) {
|
||||
if (!checkOnly && !DEEPL_API_KEY) {
|
||||
console.error("❌ Missing DEEPL_API_KEY in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const translator = requireApiKey ? new deepl.Translator(DEEPL_API_KEY) : null;
|
||||
const translator = checkOnly ? null : new deepl.Translator(DEEPL_API_KEY);
|
||||
|
||||
const deeplLangMap = {
|
||||
'zh-cn': 'ZH-HANS',
|
||||
@@ -98,23 +95,8 @@ 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 = [], doEnforceOrder, doCleanupOnly = false) {
|
||||
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = []) {
|
||||
let changesMade = false;
|
||||
let meaningfulChanges = 0; // Only count meaningful changes
|
||||
|
||||
@@ -127,17 +109,19 @@ 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 && !doCleanupOnly) {
|
||||
if (!checkOnly && !doEnforceOrder) {
|
||||
if (key === 'languages' && currentPathParts.length === 0) {
|
||||
if (checkOnly) {
|
||||
// Don't print or count languages object copying - it's routine
|
||||
} else {
|
||||
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 (!doCleanupOnly && (!targetObj[key] || typeof targetObj[key] !== 'object')) {
|
||||
if (!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
|
||||
@@ -149,55 +133,41 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
|
||||
if (targetObj[key] && typeof targetObj[key] === 'object') {
|
||||
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
|
||||
doEnforceOrder, doCleanupOnly);
|
||||
if (result == "UNSUPPORTED") {
|
||||
const result = await processKeys(masterValue, targetObj[key], targetLangCode, currentPathPartsNext);
|
||||
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;
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
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";
|
||||
}
|
||||
targetObj[key] = result;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,7 +177,7 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
|
||||
const keysToRemove = [];
|
||||
for (const key in targetObj) {
|
||||
if (Object.prototype.hasOwnProperty.call(targetObj, key)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(masterObj, key)) {
|
||||
if (!masterObj.hasOwnProperty(key)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
@@ -230,17 +200,9 @@ 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 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) {
|
||||
} else {
|
||||
console.warn("--- Using DeepL API for translation ---");
|
||||
}
|
||||
|
||||
@@ -258,61 +220,38 @@ 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 {
|
||||
originalTargetContent = await fs.readJson(targetFile);
|
||||
targetContent = JSON.parse(JSON.stringify(originalTargetContent)); // start from original
|
||||
targetContent = await fs.readJson(targetFile);
|
||||
} 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, [], 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`);
|
||||
}
|
||||
}
|
||||
const result = await processKeys(masterContent, targetContent, targetLangCode);
|
||||
|
||||
if (checkOnly) {
|
||||
let wouldChange = false;
|
||||
if (typeof result === 'number' && result > 0) {
|
||||
meaningfulChanges += result;
|
||||
wouldChange = true;
|
||||
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
|
||||
}
|
||||
if (!fileExisted && !isCleanup) {
|
||||
wouldChange = true;
|
||||
console.log(`Would create new file for ${targetLangCode}.json`);
|
||||
}
|
||||
if (wouldChange) {
|
||||
hasMeaningfulChanges = true;
|
||||
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
|
||||
} else if (!fileExisted) {
|
||||
meaningfulChanges++;
|
||||
hasMeaningfulChanges = true;
|
||||
console.log(`Would create new file for ${targetLangCode}.json`);
|
||||
} else {
|
||||
// Only print if there are meaningful changes
|
||||
}
|
||||
} else {
|
||||
const needsWrite = result || orderChanged || (!isCleanup && !fileExisted);
|
||||
if (needsWrite) {
|
||||
const finalContent = (isEnforceOrder && orderChanged) ? reorderedContent : targetContent;
|
||||
if (result || !fileExisted) {
|
||||
try {
|
||||
await fs.writeJson(targetFile, finalContent, { spaces: 2 });
|
||||
if (!isCleanup && !isEnforceOrder) {
|
||||
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
|
||||
}
|
||||
await fs.writeJson(targetFile, targetContent, { spaces: 2 });
|
||||
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${targetFile}:`, error);
|
||||
}
|
||||
@@ -331,13 +270,7 @@ async function syncAllTranslations() {
|
||||
return 0; // Exit code 0 for no meaningful changes
|
||||
}
|
||||
} else {
|
||||
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).');
|
||||
}
|
||||
console.log('\n✅ Translation synchronization complete (via DeepL).');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,19 +83,13 @@ 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),
|
||||
}
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const msg = body.message || 'Failed to generate OTP'
|
||||
throw new Error(msg)
|
||||
}
|
||||
return body
|
||||
return await res.json()
|
||||
} catch (error) {
|
||||
notify.showError(error.message || error || 'Failed to generate OTP')
|
||||
notify.showError(error || 'Failed to generate OTP')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -107,26 +101,16 @@ 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) {
|
||||
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)
|
||||
throw new Error('Failed to verify OTP')
|
||||
}
|
||||
} catch (error) {
|
||||
notify.showError(error.message || error || 'Failed to verify OTP')
|
||||
notify.showError(error || 'Failed to verify OTP')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fetchURL, adjustedData } from "./utils";
|
||||
import { fetchURL } from "./utils";
|
||||
import { notify } from "@/notify";
|
||||
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
|
||||
import { state } from "@/store";
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
|
||||
// GET /api/media/subtitles
|
||||
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
|
||||
@@ -19,50 +18,4 @@ 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);
|
||||
}
|
||||
@@ -47,31 +47,6 @@ 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')
|
||||
@@ -219,19 +194,6 @@ 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
|
||||
|
||||
@@ -297,18 +259,7 @@ async function downloadChunked(file, shareHash = "") {
|
||||
})
|
||||
|
||||
if (!response.ok && response.status !== 206) {
|
||||
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"
|
||||
)
|
||||
throw new Error(`Failed to download chunk: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Track progress within the chunk using ReadableStream
|
||||
@@ -352,10 +303,6 @@ 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;
|
||||
@@ -408,9 +355,8 @@ async function downloadChunked(file, shareHash = "") {
|
||||
// Don't throw error or show notification for user-initiated cancellation
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message || "Unknown error" : String(error)
|
||||
downloadManager.setError(downloadId, message)
|
||||
downloadManager.setError(downloadId, error.message || 'Download failed')
|
||||
notify.showError(`Chunked download failed: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -462,18 +408,7 @@ export function post(
|
||||
error.response = { status: request.status, responseText: request.responseText };
|
||||
reject(error);
|
||||
} else {
|
||||
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);
|
||||
reject(new Error(request.responseText || "Upload failed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -86,9 +86,9 @@ export async function fileWatcherLatencyCheck() {
|
||||
}
|
||||
|
||||
// GET /api/tools/fileWatcher
|
||||
export async function fileWatcher(source, path, lines) {
|
||||
export async function fileWatcher(source, path) {
|
||||
try {
|
||||
const params = { source, path, lines: lines };
|
||||
const params = { source, path };
|
||||
const apiPath = getApiPath("tools/fileWatcher", params);
|
||||
const res = await fetchURL(apiPath);
|
||||
return await res.json();
|
||||
|
||||
+28
-163
@@ -1,9 +1,6 @@
|
||||
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() {
|
||||
@@ -28,82 +25,36 @@ export async function get(id) {
|
||||
}
|
||||
|
||||
// POST /api/users (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')
|
||||
|
||||
export async function create(user) {
|
||||
try {
|
||||
const apiPath = getApiPath('users')
|
||||
const res = await fetchURL(apiPath, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: mergedHeaders,
|
||||
})
|
||||
if (res.status === 201) {
|
||||
return res.headers.get('Location')
|
||||
}
|
||||
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)
|
||||
}
|
||||
},
|
||||
},
|
||||
body: JSON.stringify({
|
||||
which: [],
|
||||
data: user
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/users (update 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 update(user, which = ['all'], options = {}) {
|
||||
export async function update(user, which = ['all']) {
|
||||
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 = {}
|
||||
@@ -115,111 +66,25 @@ export async function update(user, which = ['all'], options = {}) {
|
||||
}
|
||||
|
||||
const apiPath = getApiPath('users', { id: user.id })
|
||||
const body = JSON.stringify({
|
||||
which: which,
|
||||
data: userData
|
||||
await fetchURL(apiPath, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
which: which,
|
||||
data: userData
|
||||
})
|
||||
})
|
||||
|
||||
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: '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')
|
||||
|
||||
export async function remove(id) {
|
||||
try {
|
||||
const apiPath = getApiPath('users', { id: id })
|
||||
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)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
method: 'DELETE'
|
||||
})
|
||||
} catch (err) {
|
||||
notify.showError(err.message || `Failed to delete user with ID: ${id}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
:title="label"
|
||||
class="action no-select"
|
||||
>
|
||||
<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>
|
||||
<i v-if="icon == 'table_rows_narrow'" class="material-symbols-outlined">{{ icon }}</i>
|
||||
<i v-else class="material-icons">{{ icon }}</i>
|
||||
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="counter > 0" class="counter">{{ counter }}</span>
|
||||
|
||||
@@ -10,66 +10,47 @@
|
||||
ref="contextMenu"
|
||||
v-if="showContext"
|
||||
:style="centered ? {} : { top: posY + 'px', left: posX + 'px' }"
|
||||
class="no-select floating-window"
|
||||
class="button no-select floating-window"
|
||||
:class="{ 'dark-mode': isDarkMode, 'centered': centered }"
|
||||
:key="showCreate ? 'create-mode' : 'normal-mode'"
|
||||
>
|
||||
<div
|
||||
v-if="showCreateToggle"
|
||||
class="context-menu-header"
|
||||
>
|
||||
<div v-if="!showLimitedOptions" class="context-menu-header">
|
||||
<div
|
||||
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"
|
||||
class="action button clickable"
|
||||
v-if="showCreateButton"
|
||||
@click="toggleShowCreate"
|
||||
>
|
||||
<i v-if="!showCreate" class="material-symbols">add</i>
|
||||
<i v-else class="material-symbols">arrow_back</i>
|
||||
<i v-if="!showCreate" class="material-icons">add</i>
|
||||
<i v-else class="material-icons">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="showCreateFileActions"
|
||||
v-if="showCreateActions"
|
||||
icon="create_new_folder"
|
||||
:label="$t('files.newFolder')"
|
||||
@action="showNewDirPrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateFileActions"
|
||||
v-if="showCreateActions"
|
||||
icon="note_add"
|
||||
:label="$t('files.newFile')"
|
||||
@action="showPrompt('newFile')"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateFileActions"
|
||||
v-if="showCreateActions"
|
||||
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"
|
||||
@@ -83,6 +64,12 @@
|
||||
:label="$t('general.download')"
|
||||
@action="startDownload"
|
||||
/>
|
||||
<action
|
||||
v-if="showArchive"
|
||||
icon="archive"
|
||||
:label="$t('prompts.archive')"
|
||||
@action="showArchivePrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showUnarchive"
|
||||
icon="unarchive"
|
||||
@@ -97,22 +84,16 @@
|
||||
/>
|
||||
<action
|
||||
v-if="showRename"
|
||||
icon="edit"
|
||||
icon="mode_edit"
|
||||
:label="$t('general.rename')"
|
||||
@action="showRenamePrompt"
|
||||
/>
|
||||
<action
|
||||
v-if="showCopy"
|
||||
icon="file_copy"
|
||||
icon="content_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"
|
||||
@@ -171,13 +152,13 @@
|
||||
top: '3em',
|
||||
right: '1em',
|
||||
}"
|
||||
class="no-select floating-window"
|
||||
class="button 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="edit" :label="$t('general.rename')" @action="showRenamePromptForPreview" />
|
||||
<action v-if="isPreview && permissions.modify" icon="mode_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" />
|
||||
@@ -198,7 +179,6 @@ 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;
|
||||
@@ -241,10 +221,7 @@ export default {
|
||||
computed: {
|
||||
// Either from prop or from state
|
||||
providedItems() {
|
||||
// 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;
|
||||
}
|
||||
if (this.items) return this.items;
|
||||
// Fallback to global selection (indices or objects)
|
||||
if (state.selected.length === 0) return [];
|
||||
// Map to actual items from state.req
|
||||
@@ -268,60 +245,39 @@ 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 &&
|
||||
!this.isSearchActive
|
||||
);
|
||||
return !this.showCreate && this.selectedCount > 0 && !this.showUnarchive;
|
||||
},
|
||||
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.selectedCount <= 1 && this.showShare;
|
||||
return (this.showCreate || this.selectedCount == 1) && this.showShare;
|
||||
},
|
||||
showRename() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
@@ -331,10 +287,6 @@ 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);
|
||||
},
|
||||
@@ -346,17 +298,16 @@ export default {
|
||||
if (this.showLimitedOptions) return false;
|
||||
return !this.showCreate && !this.isSearchActive && this.req?.items?.length > 0;
|
||||
},
|
||||
/** + control in listing / share directory context; disabled when the panel cannot be opened. */
|
||||
showCreateToggle() {
|
||||
showCreateButton() {
|
||||
if (this.showLimitedOptions || this.createOnly) return false;
|
||||
return !this.isSearchActive;
|
||||
},
|
||||
createToggleDisabled() {
|
||||
return !this.canOpenCreatePanel;
|
||||
return !this.isSearchActive && this.permissions.create && !this.isShare;
|
||||
},
|
||||
showDivider() {
|
||||
if (this.showLimitedOptions || this.createOnly) return false;
|
||||
return this.showCreateToggle || this.selectedCount > 0;
|
||||
if (getters.isShare()) {
|
||||
return state.shareInfo?.allowCreate
|
||||
}
|
||||
return state.user?.permissions?.create || state.user?.permissions?.share || state.user?.permissions?.admin;
|
||||
},
|
||||
showSelectMultiple() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
@@ -396,14 +347,11 @@ export default {
|
||||
return false;
|
||||
}
|
||||
const cv = getters.currentView();
|
||||
return (
|
||||
cv != "settings" &&
|
||||
!this.isSearchActive &&
|
||||
!!this.permissions.delete
|
||||
);
|
||||
const showDelete = cv != "settings" && !this.isSearchActive && this.permissions.delete;
|
||||
return showDelete;
|
||||
},
|
||||
hasDownload() {
|
||||
return this.selectedCount > 0 && !!this.permissions.download;
|
||||
return this.selectedCount > 0 && this.permissions.download;
|
||||
},
|
||||
isPreview() {
|
||||
return getters.isPreviewView();
|
||||
@@ -420,10 +368,7 @@ export default {
|
||||
if (getters.isShare()) {
|
||||
return false;
|
||||
}
|
||||
if (this.selectedCount > 1) {
|
||||
return false;
|
||||
}
|
||||
return getters.isAdmin() && this.showCreate;
|
||||
return this.permissions.admin && this.showCreate;
|
||||
},
|
||||
showShare() {
|
||||
if (getters.isShare()) {
|
||||
@@ -519,16 +464,8 @@ 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.canOpenCreatePanel) {
|
||||
if (!this.permissions.create) {
|
||||
this.showCreate = false;
|
||||
return;
|
||||
}
|
||||
@@ -539,9 +476,11 @@ export default {
|
||||
},
|
||||
showAccessPrompt() {
|
||||
mutations.closeHovers();
|
||||
const item = this.firstSelected;
|
||||
let sourceName = item?.source || state.req.source;
|
||||
let path = item?.path || state.req.path;
|
||||
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) + '/';
|
||||
}
|
||||
mutations.showPrompt({
|
||||
name: "access",
|
||||
props: {
|
||||
@@ -613,7 +552,7 @@ export default {
|
||||
}, 300);
|
||||
},
|
||||
startShowCreate() {
|
||||
if (!this.canOpenCreatePanel) {
|
||||
if (!this.permissions.create) {
|
||||
return;
|
||||
}
|
||||
this.showCreate = true;
|
||||
@@ -658,12 +597,17 @@ export default {
|
||||
this.posY = contextProps.posY;
|
||||
},
|
||||
initializeCreateState() {
|
||||
// If createOnly is set, always show create actions
|
||||
if (this.createOnly) {
|
||||
this.showCreate = true;
|
||||
return;
|
||||
}
|
||||
// Right-click / prompt: start in normal mode; user opens create via + or sidebar (createOnly).
|
||||
this.showCreate = false;
|
||||
// Only set initial showCreate state, don't override user choices
|
||||
if (this.selectedCount > 0 || !this.permissions.create) {
|
||||
this.showCreate = false;
|
||||
} else {
|
||||
this.showCreate = true;
|
||||
}
|
||||
},
|
||||
toggleMultipleSelection() {
|
||||
mutations.setMultiple(true);
|
||||
@@ -703,13 +647,6 @@ 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
|
||||
@@ -855,14 +792,12 @@ export default {
|
||||
z-index: 1000;
|
||||
background-color: var(--background);
|
||||
max-width: 20em;
|
||||
min-width: 13em;
|
||||
min-width: 15em;
|
||||
min-height: 4em;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-radius: 1em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
#context-menu.centered {
|
||||
@@ -877,27 +812,10 @@ 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;
|
||||
@@ -937,7 +855,5 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
</style>
|
||||
@@ -19,10 +19,10 @@
|
||||
}"
|
||||
>
|
||||
<!-- Close button - always present on every notification, separate from optional buttons array -->
|
||||
<i class="material-symbols" @click="closeNotification(notification.id)">close</i>
|
||||
<i class="material-icons" @click="closeNotification(notification.id)">close</i>
|
||||
<div class="notification-content-wrapper">
|
||||
<div class="notification-header">
|
||||
<i v-if="notification.icon" class="material-symbols notification-icon">
|
||||
<i v-if="notification.icon" class="material-icons 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-symbols:first-child {
|
||||
.notification-item > .material-icons:first-child {
|
||||
cursor: pointer;
|
||||
font-size: 1.75em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.notification-item > .material-symbols:first-child:hover {
|
||||
.notification-item > .material-icons:first-child:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
|
||||
{{ displayed_text }}
|
||||
<i
|
||||
v-if="helpText && status === 'error'"
|
||||
class="material-symbols-outlined tooltip-info-icon"
|
||||
class="no-select material-symbols-outlined tooltip-info-icon"
|
||||
@mouseenter="showTooltip"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
@@ -354,9 +354,7 @@ export default {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tooltip-info-icon:hover,
|
||||
.tooltip-info-icon:hover .material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1;
|
||||
.tooltip-info-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -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-symbols">close</i>
|
||||
<i class="material-icons">close</i>
|
||||
</button>
|
||||
<!-- Search icon when search is not active -->
|
||||
<i v-else class="material-symbols">search</i>
|
||||
<i v-else class="material-icons">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="isRunning" size="medium" />
|
||||
<LoadingSpinner v-if="ongoing" size="medium" />
|
||||
<!-- Message when no results are found -->
|
||||
<div class="searchPrompt" v-show="isEmpty && !isRunning">
|
||||
<p>{{ noneMessage }}</p>
|
||||
<i class="material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
|
||||
<i class="no-select 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.audio"), value: "type:audio" },
|
||||
{ label: this.$t("general.audios"), 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: 0,
|
||||
ongoing: false,
|
||||
results: [],
|
||||
reload: false,
|
||||
scrollable: null,
|
||||
@@ -181,7 +181,7 @@ export default {
|
||||
},
|
||||
value() {
|
||||
if (this.results.length) {
|
||||
this.ongoing = 0;
|
||||
this.ongoing = false;
|
||||
this.results = [];
|
||||
}
|
||||
},
|
||||
@@ -207,7 +207,7 @@ export default {
|
||||
|
||||
// Add keyboard event listener for "/" to activate search
|
||||
this.handleKeydown = (event) => {
|
||||
if (event.key === '/' || event.key === ' ' && !state.isSearchActive && getters.currentPrompt() === null) {
|
||||
if (event.key === '/' && !state.isSearchActive && getters.currentPrompt() === null) {
|
||||
event.preventDefault();
|
||||
this.open();
|
||||
}
|
||||
@@ -267,16 +267,17 @@ export default {
|
||||
return this.results.length === 0;
|
||||
},
|
||||
text() {
|
||||
if (this.ongoing > 0) {
|
||||
if (this.ongoing) {
|
||||
return "";
|
||||
}
|
||||
return this.$t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength })
|
||||
},
|
||||
isRunning() {
|
||||
return this.ongoing > 0;
|
||||
return this.ongoing;
|
||||
},
|
||||
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);
|
||||
@@ -380,6 +381,9 @@ 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" }];
|
||||
@@ -463,11 +467,12 @@ export default {
|
||||
},
|
||||
async submit(event) {
|
||||
this.results = [];
|
||||
|
||||
if (event != undefined) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (this.value === "" || this.value.length < globalVars.minSearchLength) {
|
||||
this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: globalVars.minSearchLength });
|
||||
this.ongoing = false; this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: globalVars.minSearchLength });
|
||||
return;
|
||||
}
|
||||
let searchTypesFull = this.searchTypes;
|
||||
@@ -486,7 +491,7 @@ export default {
|
||||
if (newerUnix !== null) {
|
||||
dateParams.newerThan = newerUnix;
|
||||
}
|
||||
this.ongoing++;
|
||||
this.ongoing = true;
|
||||
|
||||
// Determine which sources to search
|
||||
let sourcesToSearch = [];
|
||||
@@ -509,13 +514,13 @@ export default {
|
||||
dateParams
|
||||
);
|
||||
|
||||
this.ongoing--;
|
||||
if (this.results.length == 0 && this.ongoing == 0) {
|
||||
this.ongoing = false;
|
||||
if (this.results.length == 0) {
|
||||
this.noneMessage = this.$t("search.noResults");
|
||||
}
|
||||
},
|
||||
showHelpTooltip(event) {
|
||||
const helpText = `${this.$t("search.helpText1", { minSearchLength: globalVars.minSearchLength })}\n\n${this.$t("search.helpText2")}`;
|
||||
const helpText = `${this.$t("search.helpText1")}\n\n${this.$t("search.helpText2")}`;
|
||||
mutations.showTooltip({
|
||||
content: helpText,
|
||||
x: event.clientX,
|
||||
@@ -546,26 +551,6 @@ 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("/");
|
||||
@@ -709,12 +694,12 @@ export default {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input-container .material-symbols {
|
||||
.search-input-container .material-icons {
|
||||
font-size: 1.25em;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
#search.active .search-input-container .material-symbols {
|
||||
#search.active .search-input-container .material-icons {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
<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>
|
||||
<!-- 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 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>
|
||||
</div>
|
||||
<!-- Right side: Slider to control the listing size or the font size in editor -->
|
||||
<div class="status-controls">
|
||||
<div v-if="currentView === 'listingView'" class="gallery-size-control">
|
||||
<div v-if="showGallerySize" class="gallery-size-control">
|
||||
<span class="size-label">{{ $t("general.size") }}</span>
|
||||
<input
|
||||
v-model="gallerySize"
|
||||
@@ -27,17 +22,6 @@
|
||||
@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>
|
||||
@@ -55,17 +39,11 @@ 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 this.currentView === "listingView" || this.isEditorOrMarkdownView;
|
||||
return getters.currentView() === "listingView";
|
||||
},
|
||||
isDarkMode() {
|
||||
return getters.isDarkMode();
|
||||
@@ -79,6 +57,9 @@ 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);
|
||||
@@ -136,30 +117,6 @@ 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) {
|
||||
@@ -200,28 +157,6 @@ 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>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getIconClass } from "@/utils/material-symbols";
|
||||
import { getIconClass } from "@/utils/material-icons";
|
||||
|
||||
export default {
|
||||
name: "Toast",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@dragleave.prevent="dragLeave($event, homeLink)"
|
||||
@dragover.prevent="dragOver($event, homeLink)"
|
||||
@drop.prevent="drop($event, homeLink)">
|
||||
<i class="material-symbols">home</i>
|
||||
<i class="material-icons">home</i>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="item" v-for="(link, index) in items" :key="index">
|
||||
@@ -223,8 +223,7 @@ export default {
|
||||
fromSource: selectedItem.source,
|
||||
to: url.joinPath(targetPath, selectedItem.name),
|
||||
toSource: source,
|
||||
itemType: selectedItem.type,
|
||||
name: selectedItem.name
|
||||
itemType: selectedItem.type
|
||||
});
|
||||
}
|
||||
|
||||
@@ -256,7 +255,7 @@ export default {
|
||||
targetDirItems = response?.items;
|
||||
}
|
||||
} catch (error) {
|
||||
notify.showError(this.$t("files.cannotAccesDir"));
|
||||
notify.showErrorToast(this.$t("files.cannotAccesDir"));
|
||||
console.log("Cannot access to target directory", e);
|
||||
return;
|
||||
}
|
||||
@@ -296,7 +295,7 @@ export default {
|
||||
mutations.setReload(true);
|
||||
} catch (error) {
|
||||
mutations.closeTopPrompt();
|
||||
notify.showError(this.$t("prompts.moveFailed"));
|
||||
notify.showErrorToast(this.$t("prompts.moveFailed"));
|
||||
console.log("Move failed", e);
|
||||
}
|
||||
};
|
||||
@@ -325,8 +324,6 @@ export default {
|
||||
<style scoped>
|
||||
#breadcrumbs {
|
||||
overflow-x: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
@@ -334,6 +331,12 @@ 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;
|
||||
}
|
||||
@@ -347,16 +350,15 @@ export default {
|
||||
|
||||
#breadcrumbs ul li {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li a {
|
||||
display: flex;
|
||||
height: 0.85em;
|
||||
background: var(--surfacePrimary);
|
||||
background: color-mix(in srgb, var(--alt-background) 90%, transparent);
|
||||
text-align: center;
|
||||
padding: 0.85em;
|
||||
padding-right: 1.6em;
|
||||
padding-left: 1.7em;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
@@ -368,15 +370,6 @@ 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 {
|
||||
@@ -386,29 +379,51 @@ 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;
|
||||
clip-path: polygon(
|
||||
0% 0%,
|
||||
calc(100% - 1.275em) 0%,
|
||||
100% 50%,
|
||||
calc(100% - 1.275em) 100%,
|
||||
0% 100%);
|
||||
padding-left: 1.1em;
|
||||
padding-left: 1.275em;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:first-child a::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:last-child a {
|
||||
padding-right: 1.275em;
|
||||
border-top-right-radius: 0.85em;
|
||||
border-bottom-right-radius: 0.85em;
|
||||
clip-path: polygon(
|
||||
0% 0%,
|
||||
100% 0%,
|
||||
100% 100%,
|
||||
0% 100%,
|
||||
1.275em 50%);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:last-child a::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li a:hover {
|
||||
@@ -416,6 +431,10 @@ 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);
|
||||
}
|
||||
@@ -426,6 +445,10 @@ 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); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="image-ex-container" ref="container" @touchstart="touchStart" @touchmove.prevent="touchMove" @touchend="touchEnd" @dblclick="zoomAuto"
|
||||
<div class="image-ex-container" ref="container" @touchstart="touchStart" @touchmove="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,25 +76,18 @@ export default {
|
||||
maxScale: 4,
|
||||
minScale: 1,
|
||||
isTiff: 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,
|
||||
// Swipe navigation properties
|
||||
swipeStartTime: null,
|
||||
swipeStartX: 0,
|
||||
swipeStartY: 0,
|
||||
swipeCurrentX: 0,
|
||||
swipeCurrentY: 0,
|
||||
isSwipeGesture: false,
|
||||
hasStartedSwipe: false,
|
||||
gestureDecided: false,
|
||||
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,
|
||||
swipeMinDistance: 150,
|
||||
swipeMaxTime: 500,
|
||||
swipeMaxVerticalDistance: 50,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -126,38 +119,6 @@ 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);
|
||||
@@ -191,32 +152,10 @@ 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);
|
||||
@@ -240,11 +179,8 @@ 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;
|
||||
@@ -265,7 +201,6 @@ export default {
|
||||
if (img) {
|
||||
img.style.display = 'block';
|
||||
}
|
||||
this.scheduleSetCenter();
|
||||
},
|
||||
checkIfTiff(src) {
|
||||
const sufs = ["tif", "tiff", "dng", "cr2", "nef"];
|
||||
@@ -291,295 +226,44 @@ 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);
|
||||
this.resetEdgeGestureImmediate();
|
||||
this.applyImgTransform();
|
||||
// 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})`;
|
||||
}
|
||||
},
|
||||
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 (event.button !== 0) return;
|
||||
if (this.scale > 1 && this.inDrag) {
|
||||
this.doMove(event.movementX, event.movementY);
|
||||
event.preventDefault();
|
||||
}
|
||||
if (!this.inDrag) return;
|
||||
this.doMove(event.movementX, event.movementY);
|
||||
event.preventDefault();
|
||||
},
|
||||
mouseUp(event) {
|
||||
if (event.button !== 0) return;
|
||||
if (this.scale > 1) {
|
||||
this.inDrag = false;
|
||||
event.preventDefault();
|
||||
}
|
||||
this.inDrag = false;
|
||||
event.preventDefault();
|
||||
},
|
||||
touchStart(event) {
|
||||
this.lastX = null;
|
||||
@@ -587,15 +271,16 @@ export default {
|
||||
this.lastTouchDistance = null;
|
||||
if (event.targetTouches.length === 1 && this.scale === 1) {
|
||||
const touch = event.targetTouches[0];
|
||||
this.edgeStartX = touch.pageX;
|
||||
this.edgeStartY = touch.pageY;
|
||||
this.edgeDx = 0;
|
||||
this.edgeDy = 0;
|
||||
this.edgeKind = null;
|
||||
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.gestureDecided = false;
|
||||
} else {
|
||||
this.resetEdgeGestureImmediate();
|
||||
this.applyImgTransform();
|
||||
this.resetSwipeTracking();
|
||||
}
|
||||
if (event.targetTouches.length < 2) {
|
||||
setTimeout(() => {
|
||||
@@ -629,15 +314,30 @@ 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.edgeDx = touch.pageX - this.edgeStartX;
|
||||
this.edgeDy = touch.pageY - this.edgeStartY;
|
||||
this.decideEdgeKind();
|
||||
this.applyEdgeVisuals();
|
||||
return;
|
||||
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();
|
||||
}
|
||||
if (this.lastX === null) {
|
||||
this.lastX = event.targetTouches[0].pageX;
|
||||
@@ -677,7 +377,11 @@ export default {
|
||||
doMove(x, y) {
|
||||
this.position.relative.x += x;
|
||||
this.position.relative.y += y;
|
||||
this.applyImgTransform();
|
||||
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})`;
|
||||
}
|
||||
},
|
||||
wheelMove(event) {
|
||||
event.preventDefault()
|
||||
@@ -688,23 +392,52 @@ 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();
|
||||
}
|
||||
this.applyImgTransform();
|
||||
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})`;
|
||||
}
|
||||
},
|
||||
pxStringToNumber(style) {
|
||||
return +style.replace("px", "");
|
||||
},
|
||||
touchEnd(event) {
|
||||
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) {
|
||||
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) {
|
||||
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: {
|
||||
@@ -738,9 +471,7 @@ 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
|
||||
});
|
||||
@@ -748,8 +479,7 @@ export default {
|
||||
|
||||
this.scale = 1;
|
||||
this.position.relative = { x: 0, y: 0 };
|
||||
this.resetEdgeGestureImmediate();
|
||||
this.applyImgTransform();
|
||||
this.resetSwipeTracking();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -757,18 +487,12 @@ 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 {
|
||||
@@ -777,8 +501,7 @@ export default {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
/* translate3d tends to composite more reliably than translate with decoded <img> bitmaps */
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
transform: translate(-50%, -50%);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -786,7 +509,7 @@ export default {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="card-content" aria-label="file-list-prompt">
|
||||
<!-- Source Selection Dropdown (hidden when browsing a fixed source, e.g. user scope row) -->
|
||||
<!-- Source Selection Dropdown -->
|
||||
<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,11 +96,6 @@ 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
|
||||
@@ -143,9 +138,6 @@ 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() {
|
||||
@@ -457,7 +449,6 @@ 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,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<!-- Error state -->
|
||||
<div v-if="isRootInstance && error" class="tree-error">
|
||||
<i class="material-symbols">error</i>
|
||||
<i class="material-icons">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-symbols">
|
||||
<i class="material-icons">
|
||||
{{ 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-symbols-outlined">sentiment_dissatisfied</i>
|
||||
<i class="material-icons">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-symbols-outlined">sentiment_dissatisfied</i>
|
||||
<i class="material-icons">sentiment_dissatisfied</i>
|
||||
{{ $t('files.lonely') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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-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>
|
||||
<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>
|
||||
<!-- 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 }]"> {{ materialSymbol }} </i>
|
||||
<i v-if="isShared" class="material-symbols overlay-icon">group</i>
|
||||
<i :class="[classes, { active: active, clickable: clickable }]"> {{ materialIcon }} </i>
|
||||
<i v-if="isShared" class="material-icons overlay-icon">group</i>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -95,7 +95,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
materialSymbol: "",
|
||||
materialIcon: "",
|
||||
classes: "",
|
||||
svgPath: "",
|
||||
previewTimeouts: [],
|
||||
@@ -213,6 +213,9 @@ 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;
|
||||
@@ -421,10 +424,10 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
const result = this.getIconForType();
|
||||
this.classes = result.classes || "material-symbols";
|
||||
this.classes = result.classes || "material-icons";
|
||||
// @ts-ignore
|
||||
this.color = result.color || "lightgray";
|
||||
this.materialSymbol = result.materialSymbol || "";
|
||||
this.materialIcon = result.materialIcon || "";
|
||||
// @ts-ignore
|
||||
this.svgPath = result.svgPath || "";
|
||||
this.updateImageTargetSrc();
|
||||
@@ -450,6 +453,9 @@ export default {
|
||||
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
|
||||
z-index: 2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.overlay-icon.muted {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -468,11 +474,13 @@ 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);
|
||||
}
|
||||
@@ -481,9 +489,16 @@ export default {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
/* -----------
|
||||
Icon Colors
|
||||
----------- */
|
||||
.purple-icons {
|
||||
color: purple;
|
||||
}
|
||||
|
||||
/* Icon Colors */
|
||||
.blue-icons {
|
||||
color: var(--icon-blue);
|
||||
}
|
||||
|
||||
/* Icon Colors */
|
||||
.primary-icons {
|
||||
color: var(--primaryColor);
|
||||
}
|
||||
@@ -492,55 +507,11 @@ 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;
|
||||
}
|
||||
|
||||
@@ -548,31 +519,53 @@ export default {
|
||||
color: tan;
|
||||
}
|
||||
|
||||
/* green variations */
|
||||
.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-icons {
|
||||
color: rgb(23, 128, 74);
|
||||
}
|
||||
|
||||
.light-green-icons {
|
||||
color: rgb(48, 207, 117);
|
||||
}
|
||||
|
||||
/* white variations */
|
||||
.white-icons {
|
||||
color: white;
|
||||
.red-orange-icons {
|
||||
color: rgb(255, 147, 111);
|
||||
}
|
||||
|
||||
.gray-icons {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.skyblue-icons {
|
||||
color: rgb(42, 170, 242);
|
||||
}
|
||||
|
||||
.lightgray-icons {
|
||||
color: rgb(176, 176, 176);
|
||||
}
|
||||
|
||||
#listingView.gallery .listing-item i.white-icons,
|
||||
.active.white-icons {
|
||||
color: var(--activeWhiteIcon);
|
||||
.yellow-icons {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
/* Unified .image-preview container - works universally, always square */
|
||||
@@ -601,9 +594,6 @@ 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-symbols">{{ nameIcon }}</i>
|
||||
<i v-if="nameSorted" class="material-icons">{{ 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,14 +188,10 @@ span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.desktop-view .modified{
|
||||
min-width: 15%;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.desktop-view .size,
|
||||
.desktop-view .modified,
|
||||
.desktop-view .duration {
|
||||
min-width: 10%;
|
||||
min-width: 15%;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
@@ -203,8 +199,7 @@ span {
|
||||
.modified,
|
||||
.duration {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
text-align: end;
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user