mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
107
Commits
main
...
v1.2.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb518bac56 | ||
|
|
d82e7f2986 | ||
|
|
7cd3f8374e | ||
|
|
4e0f0453f3 | ||
|
|
98246b2981 | ||
|
|
d2c07ffd3c | ||
|
|
43cbadbeb8 | ||
|
|
25dc426522 | ||
|
|
27dbd509b2 | ||
|
|
3716c4e4c6 | ||
|
|
7aabdadf3e | ||
|
|
264de1ce88 | ||
|
|
4aae24cf11 | ||
|
|
0968c44d22 | ||
|
|
a15fe4eaa5 | ||
|
|
a433a8a8d4 | ||
|
|
b5219ae6b6 | ||
|
|
d9f91c7c65 | ||
|
|
4a85746850 | ||
|
|
3ae8aede25 | ||
|
|
3990890721 | ||
|
|
726116d831 | ||
|
|
8274f2c715 | ||
|
|
32969d0777 | ||
|
|
8f25186136 | ||
|
|
e18a73c97a | ||
|
|
e5cb88ba5d | ||
|
|
50b9ee8b31 | ||
|
|
a0e266fadf | ||
|
|
377ae33e1b | ||
|
|
e14f2d7a4b | ||
|
|
fac091f1b3 | ||
|
|
5330d6b093 | ||
|
|
692f99251d | ||
|
|
31e70519c1 | ||
|
|
0b88a90048 | ||
|
|
79d7f07943 | ||
|
|
b562edaaeb | ||
|
|
33ede042da | ||
|
|
036a0ed6c8 | ||
|
|
1842ff561b | ||
|
|
aed9f7c135 | ||
|
|
c6600a0665 | ||
|
|
3e3d3decc6 | ||
|
|
c58b298eb5 | ||
|
|
98ba8095db | ||
|
|
68fb573059 | ||
|
|
5747c40dca | ||
|
|
598bd366db | ||
|
|
e3be3300dc | ||
|
|
416aa75446 | ||
|
|
cfb3b9e869 | ||
|
|
166f1bf232 | ||
|
|
451f9ac69f | ||
|
|
b2fa1780ed | ||
|
|
9255bc174b | ||
|
|
a3a074d7b6 | ||
|
|
aefe16b508 | ||
|
|
728ee72e2a | ||
|
|
e94075de6c | ||
|
|
c56bafb4e9 | ||
|
|
a6e045cc85 | ||
|
|
690685459e | ||
|
|
6f6244b2fb | ||
|
|
05fcd31909 | ||
|
|
a4107a734a | ||
|
|
991be79521 | ||
|
|
8755e82780 | ||
|
|
d1b3a045af | ||
|
|
b5b365c6a7 | ||
|
|
c33a876033 | ||
|
|
42778b92bb | ||
|
|
ff3b66b79d | ||
|
|
daedeef78d | ||
|
|
edba1a80ef | ||
|
|
77fb6bdabc | ||
|
|
97c37caa1c | ||
|
|
02b28284ca | ||
|
|
6a489c6f7c | ||
|
|
ba443398ca | ||
|
|
17ff68c00d | ||
|
|
cb71dd8940 | ||
|
|
764155d612 | ||
|
|
9013152d9d | ||
|
|
27bc000cda | ||
|
|
8f4e420e73 | ||
|
|
e4d8a5cf20 | ||
|
|
fa8ceb50e6 | ||
|
|
c8073db4e6 | ||
|
|
23fac83e7e | ||
|
|
f36ca31c7f | ||
|
|
c0466e96fc | ||
|
|
dc753fe326 | ||
|
|
c39a1937b4 | ||
|
|
5d38b1e66a | ||
|
|
e5e93b4b70 | ||
|
|
eaedbbe05e | ||
|
|
c6d5f32bd0 | ||
|
|
ee022bc4a9 | ||
|
|
daabefa6c3 | ||
|
|
86aebde566 | ||
|
|
a3b0fc0038 | ||
|
|
695364c2df | ||
|
|
58a01ff986 | ||
|
|
3298111560 | ||
|
|
3ca9f54de0 | ||
|
|
97d16d1663 |
No files matched your search
@@ -27,6 +27,7 @@ frontend/tsconfig.tsbuildinfo
|
||||
/backend/srv
|
||||
/backend/http/dist
|
||||
/backend/http/embed
|
||||
/backend/debug
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
|
||||
@@ -2,6 +2,52 @@
|
||||
|
||||
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.2.0-beta
|
||||
|
||||
This is a major version update with many code changes which could cause unexpected behavior. Upgrades should proceed with caution and report any undesriable behavior by opening an issue on github. The index is now fully in sqlite database - see [Indexing Overview](https://filebrowserquantum.com/en/docs/features/indexing/) for more information.
|
||||
|
||||
Sorry for the delay -- a lot of effort went into this release, specifically about fine-tuning sqlite indexing. It took me a long time to iron out the behavior, but it should be mostly there now. Enjoy and report any issues as always :)
|
||||
|
||||
**New Features**:
|
||||
- SQLite-based indexing
|
||||
- reduced memory usage, higher CPU and IO usage.
|
||||
- index persistence between restarts (requires [persistent cacheDir](https://filebrowserquantum.com/en/docs/configuration/server/#cachedir))
|
||||
- new tool - "realtime file watcher" low latency if user has realtime permissions (#917)
|
||||
- Access control works with individual files too
|
||||
- conditionally hide symbolic links as indexing rule config (#1540)
|
||||
- External applications can connect to filebrowser over webDAV (#209) (#1764)
|
||||
- make breadcrumbs act as a drop area (#1785)
|
||||
- search multiple sources at once (#848)
|
||||
- file uploads resume from bad internet connection #1599
|
||||
- chunked downloads - fix for 524 errors on cloudflare #1502
|
||||
- made size calculation consistent: defaults to "size on disk" style to mimic "du -sh", and allows config `source.config.useLogicalSize: true` for 0 size folders and actual size file sizes. (#1266)
|
||||
- Feature: allow deleting selected duplicates from duplicate finder (#1659)
|
||||
- share changes:
|
||||
- opengraph support for shared links
|
||||
- Allow adding "share settings" option to customizable sidebar links (#1825)
|
||||
- file picker for share favicon and banner icon
|
||||
- Allow disabling "Login button" for shares (#1673)
|
||||
|
||||
**Notes**:
|
||||
- `server.cacheDirCleanup` defaults to `false` instead of `true`. For docker, you would still need to mount a cacheDir volume to persist cache between restarts.
|
||||
- indexing rules have been streamlined, see [wiki]. The previous style is deprecated but still functional.
|
||||
- improved listing load times for directories with metadata -- a two-pass approach. First a fast load to get the listing items, then a second api request to include metadata.
|
||||
- Enhanced delete prompt
|
||||
- bulkDelete API added and replaces the delete API for all UI actions. See swagger docs for usage.
|
||||
- raw download and patch resource api simplified to a single source per request. See swagger docs for usage.
|
||||
- UserScope function re-organization, if you notice any user scope issues, please open a GitHub issue.
|
||||
- file upload resume from bad internet connection #1599
|
||||
|
||||
**BugFixes**:
|
||||
- Source info not properly read from external storage NAS (#1727)
|
||||
- ensure user requests scopes are strongly enforced (#1789)
|
||||
|
||||
## New Contributors
|
||||
* @reddec made their first contribution in https://github.com/gtsteffaniak/filebrowser/pull/1764
|
||||
* @maximeborges made their first contribution in https://github.com/gtsteffaniak/filebrowser/pull/1849
|
||||
|
||||
**Full Changelog**: https://github.com/gtsteffaniak/filebrowser/compare/v1.1.6-beta...v1.2.0-beta
|
||||
|
||||
## v1.1.6-beta
|
||||
|
||||
**Notes**:
|
||||
|
||||
@@ -17,7 +17,7 @@ COPY ./frontend/package.json ./
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN npm i --maxsockets 1
|
||||
RUN npm i
|
||||
COPY ./frontend/ ./
|
||||
RUN npm run build:docker
|
||||
|
||||
|
||||
@@ -8,22 +8,27 @@ server:
|
||||
name: "exclude"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
conditionals:
|
||||
rules:
|
||||
- folderPath: "excluded"
|
||||
- folderPath: "excludedButVisible"
|
||||
viewable: true
|
||||
rules:
|
||||
- folderPath: "excluded"
|
||||
- folderPath: "excludedButVisible"
|
||||
viewable: true
|
||||
- fileStartsWith: "startsWith"
|
||||
- fileName: "dontshow.txt"
|
||||
- path: "../frontend/tests/playwright-files2"
|
||||
name: "include"
|
||||
config:
|
||||
defaultEnabled: true
|
||||
useLogicalSize: true
|
||||
rules:
|
||||
- includeRootItem: "myfolder"
|
||||
- includeRootItem: "subfolderExclusions"
|
||||
- includeRootItem: "folder#hash"
|
||||
- includeRootItem: "files"
|
||||
- filePath: "/subfolderExclusions/exclusionlist.sh"
|
||||
- folderPath: "/subfolderExclusions/folderItem"
|
||||
viewable: true
|
||||
conditionals:
|
||||
rules:
|
||||
- includeRootItem: "myfolder"
|
||||
- includeRootItem: "subfolderExclusions"
|
||||
- filePath: "/subfolderExclusions/exclusionlist.sh"
|
||||
- folderPath: "/subfolderExclusions/folderItem"
|
||||
viewable: true
|
||||
- fileStartsWith: "startsWith"
|
||||
- folderEndsWith: "Folder"
|
||||
- folderNames: 'folderNames'
|
||||
|
||||
@@ -3,3 +3,4 @@ air.log
|
||||
filebrowser
|
||||
generated.yaml
|
||||
test_config.yaml
|
||||
/data
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
@@ -190,7 +193,29 @@ func TestSortItems(t *testing.T) {
|
||||
// per design decision - RealPathCache is auxiliary and doesn't need clearing
|
||||
|
||||
func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
// Initialize the index in mock mode (no filesystem operations)
|
||||
// Use a temporary directory for cache to avoid creating directories in the source tree
|
||||
tmpDir := t.TempDir()
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = tmpDir
|
||||
defer func() {
|
||||
settings.Config.Server.CacheDir = originalCacheDir
|
||||
}()
|
||||
|
||||
// Ensure fileutils permissions are set (needed by NewTempDB)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Initialize the database first (use test helper to avoid permission issues)
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
// Initialize the index with scanning disabled to prevent background scanner interference
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: "/mock/path",
|
||||
@@ -202,8 +227,18 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Create mock directory structure
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
|
||||
// Wait for initial scanner to complete (it will fail on /mock/path but needs to finish cleanup)
|
||||
// This ensures batchItems is nil before we start testing
|
||||
for i := 0; i < 50; i++ { // Wait up to 500ms
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
status := idx.GetScannerStatus()
|
||||
if status["status"] == "ready" || status["status"] == "unavailable" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock directory structure using UpdateMetadata
|
||||
rootInfo := &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
@@ -213,27 +248,28 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
{Name: "Test Object", Type: "directory"},
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Delete the old directory item from the database before replacing it with a file
|
||||
idx.DeleteMetadata("/Test Object", true, false)
|
||||
|
||||
// Simulate the directory-to-file override by updating the mock data
|
||||
// Remove the directory from the parent's Folders slice
|
||||
rootInfo := idx.Directories["/"] //nolint:staticcheck // t.Fatal terminates execution
|
||||
for i, folder := range rootInfo.Folders {
|
||||
if folder.Name == "Test Object" {
|
||||
rootInfo.Folders = append(rootInfo.Folders[:i], rootInfo.Folders[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
rootInfo.Folders = []iteminfo.ItemInfo{} // Clear folders
|
||||
|
||||
// Add the file to the parent's Files slice
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Size: 25, // Length of "This is test file content"
|
||||
rootInfo.Files = []iteminfo.ExtendedItemInfo{
|
||||
{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Size: 25, // Length of "This is test file content"
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Verify the directory was replaced with a file in the mock data
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true)
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
@@ -267,6 +303,28 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOverrideFileToDirectory(t *testing.T) {
|
||||
// Use a temporary directory for cache to avoid creating directories in the source tree
|
||||
tmpDir := t.TempDir()
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = tmpDir
|
||||
defer func() {
|
||||
settings.Config.Server.CacheDir = originalCacheDir
|
||||
}()
|
||||
|
||||
// Ensure fileutils permissions are set (needed by NewTempDB)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Initialize the database first (use test helper to avoid permission issues)
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
// Initialize the index in mock mode (no filesystem operations)
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
@@ -279,8 +337,18 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Create mock directory structure with a file
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
|
||||
// Wait for initial scanner to complete (it will fail on /mock/path but needs to finish cleanup)
|
||||
// This ensures batchItems is nil before we start testing
|
||||
for i := 0; i < 50; i++ { // Wait up to 500ms
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
status := idx.GetScannerStatus()
|
||||
if status["status"] == "ready" || status["status"] == "unavailable" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock directory structure with a file using UpdateMetadata
|
||||
rootInfo := &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
@@ -290,25 +358,26 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Delete the old file item from the database before replacing it with a directory
|
||||
idx.DeleteMetadata("/Test Object", false, false)
|
||||
|
||||
// Simulate the file-to-directory override by updating the mock data
|
||||
// Remove the file from the parent's Files slice
|
||||
rootInfo := idx.Directories["/"] //nolint:staticcheck // t.Fatal terminates execution
|
||||
for i, file := range rootInfo.Files {
|
||||
if file.Name == "Test Object" {
|
||||
rootInfo.Files = append(rootInfo.Files[:i], rootInfo.Files[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
rootInfo.Files = []iteminfo.ExtendedItemInfo{} // Clear files
|
||||
|
||||
// Add the directory to the parent's Folders slice
|
||||
rootInfo.Folders = append(rootInfo.Folders, iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Type: "directory",
|
||||
})
|
||||
rootInfo.Folders = []iteminfo.ItemInfo{
|
||||
{
|
||||
Name: "Test Object",
|
||||
Type: "directory",
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
|
||||
|
||||
// Verify the file was replaced with a directory in the mock data
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true)
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
|
||||
+237
-150
@@ -16,97 +16,45 @@ import (
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
if !strings.HasPrefix(opts.Path, "/") {
|
||||
opts.Path = "/" + opts.Path
|
||||
}
|
||||
|
||||
realPath, isDir, err := idx.GetRealPath(opts.Path)
|
||||
if err != nil && opts.FollowSymlinks {
|
||||
return response, fmt.Errorf("could not get real path for requested path: %v, error: %v", opts.Path, err)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(opts.Path, "/") && isDir {
|
||||
opts.Path = opts.Path + "/"
|
||||
}
|
||||
opts.IsDir = isDir
|
||||
var info *iteminfo.FileInfo
|
||||
|
||||
// Check if path is viewable (allows filesystem access without indexing)
|
||||
isViewable := idx.IsViewable(isDir, opts.Path)
|
||||
|
||||
// For non-viewable paths, verify they are indexed
|
||||
// Skip this check if indexing is disabled for the entire source
|
||||
if !isViewable && !idx.Config.DisableIndexing {
|
||||
err = idx.RefreshFileInfo(opts)
|
||||
if err != nil {
|
||||
return response, fmt.Errorf("path not accessible: %v", err)
|
||||
// processDirectoryMetadata extracts metadata for audio/video files in directories
|
||||
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
|
||||
metadataCount := 0
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
if isItemAudio || isItemVideo {
|
||||
metadataCount++
|
||||
}
|
||||
}
|
||||
|
||||
info, err = idx.GetFsInfo(opts.Path, opts.FollowSymlinks)
|
||||
if err != nil {
|
||||
fmt.Println("Graham 2: ", err)
|
||||
return response, err
|
||||
// Set hasMetadata flag if there are files with potential metadata
|
||||
if metadataCount > 0 {
|
||||
response.HasMetadata = true
|
||||
}
|
||||
|
||||
response.FileInfo = *info
|
||||
response.RealPath = realPath
|
||||
response.Source = opts.Source
|
||||
|
||||
if access != nil {
|
||||
err := access.CheckChildItemAccess(response, idx, opts.Username)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
|
||||
// Filter hidden files if ShowHidden is false
|
||||
if !opts.ShowHidden && isDir {
|
||||
filteredFiles := []iteminfo.ExtendedItemInfo{}
|
||||
for _, file := range response.Files {
|
||||
if !file.Hidden {
|
||||
filteredFiles = append(filteredFiles, file)
|
||||
}
|
||||
}
|
||||
response.Files = filteredFiles
|
||||
|
||||
filteredFolders := []iteminfo.ItemInfo{}
|
||||
for _, folder := range response.Folders {
|
||||
if !folder.Hidden {
|
||||
filteredFolders = append(filteredFolders, folder)
|
||||
}
|
||||
}
|
||||
response.Folders = filteredFolders
|
||||
}
|
||||
|
||||
// For directories, populate metadata for audio/video files ONLY if explicitly requested
|
||||
// This avoids expensive ffprobe calls on every directory listing
|
||||
if isDir && opts.Metadata {
|
||||
startTime := time.Now()
|
||||
metadataCount := 0
|
||||
// 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 {
|
||||
// Process files concurrently using goroutines
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // Protects metadataCount
|
||||
var mu sync.Mutex // Protects processedCount
|
||||
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
@@ -114,33 +62,25 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.E
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
|
||||
if isItemAudio || isItemVideo {
|
||||
// Get the real path for this file
|
||||
itemRealPath, _, _ := idx.GetRealPath(opts.Path, fileItem.Name)
|
||||
|
||||
// Capture loop variables in local copies to avoid closure issues
|
||||
item := fileItem
|
||||
itemPath := itemRealPath
|
||||
isAudio := isItemAudio
|
||||
|
||||
wg.Go(func() {
|
||||
// Extract metadata for audio files (without album art for performance)
|
||||
if isAudio {
|
||||
err := extractAudioMetadata(context.Background(), item, itemPath, opts.AlbumArt || opts.Content, opts.Metadata, sharedFFmpegService)
|
||||
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: "+item.Name, err)
|
||||
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
} else {
|
||||
mu.Lock()
|
||||
metadataCount++
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
} else {
|
||||
// Extract duration for video files
|
||||
err := extractVideoMetadata(context.Background(), item, itemPath, sharedFFmpegService)
|
||||
err := extractVideoMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, sharedFFmpegService)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: "+item.Name, err)
|
||||
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
} else {
|
||||
mu.Lock()
|
||||
metadataCount++
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -151,24 +91,104 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.E
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
if metadataCount > 0 {
|
||||
elapsed := time.Since(startTime)
|
||||
logger.Debugf("Extracted metadata for %d audio/video files concurrently in %v (avg: %v per file)",
|
||||
metadataCount, elapsed, elapsed/time.Duration(metadataCount))
|
||||
}
|
||||
}
|
||||
|
||||
// Extract content/metadata when explicitly requested OR for single file audio/video requests
|
||||
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
|
||||
if opts.Content || opts.Metadata || (!isDir && isAudioVideo) {
|
||||
processContent(response, idx, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
|
||||
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
|
||||
// Add OnlyOffice ID if applicable
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
response.OnlyOfficeId = generateOfficeId(realPath)
|
||||
}
|
||||
|
||||
// Strip user scope from response path to return path relative to user's context
|
||||
if user != nil && userScope != "" && userScope != "/" {
|
||||
response.Path = strings.TrimPrefix(response.Path, userScope)
|
||||
if response.Path == "" {
|
||||
response.Path = "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
if access == nil {
|
||||
return response, fmt.Errorf("access not provided")
|
||||
}
|
||||
if user == nil {
|
||||
return response, fmt.Errorf("user not provided")
|
||||
}
|
||||
if opts.Path == "" {
|
||||
return response, fmt.Errorf("path not provided")
|
||||
}
|
||||
if opts.Source == "" {
|
||||
return response, fmt.Errorf("source not provided")
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
|
||||
// Resolve user scope
|
||||
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
|
||||
if scopeErr != nil || userScope == "" {
|
||||
return response, fmt.Errorf("user has no access to source: %v", opts.Source)
|
||||
}
|
||||
|
||||
safePath, err := utils.SanitizeUserPath(opts.Path)
|
||||
if err != nil {
|
||||
return response, errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Combine scope + sanitized path
|
||||
indexPath := utils.JoinPathAsUnix(userScope, safePath)
|
||||
// Layer 1: USER ACCESS CONTROL
|
||||
// Quick check: Does THIS user have permission?
|
||||
if !access.Permitted(idx.Path, indexPath, user.Username) {
|
||||
return response, errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Layer 2: INDEX RULES (global)
|
||||
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
|
||||
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
Expand: opts.Expand,
|
||||
IsRoutineScan: false, // API call
|
||||
})
|
||||
if err != nil {
|
||||
return response, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
|
||||
// Build response
|
||||
response.FileInfo = *info
|
||||
response.RealPath = filepath.Join(idx.Path, indexPath)
|
||||
response.Source = opts.Source
|
||||
|
||||
// Layer 3: FILTER CHILDREN (user access)
|
||||
// Remove child items THIS user can't access
|
||||
if info.Type == "directory" {
|
||||
if err := access.CheckChildItemAccess(response, idx, user.Username); err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
|
||||
// Process directory metadata if requested
|
||||
if info.Type == "directory" {
|
||||
processDirectoryMetadata(response, idx, opts)
|
||||
}
|
||||
|
||||
// Process single file content/metadata
|
||||
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
|
||||
if opts.Content || opts.Metadata || isAudioVideo {
|
||||
processContent(response, idx, opts)
|
||||
}
|
||||
|
||||
// Finalize response (OnlyOffice ID, scope stripping)
|
||||
finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -225,6 +245,16 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
|
||||
// Process text content for non-video, non-audio files
|
||||
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
|
||||
// Check if file is text before reading content
|
||||
isText, err := utils.IsTextFile(info.RealPath)
|
||||
if err != nil {
|
||||
logger.Debugf("could not check file type for: "+info.RealPath, info.Name, err)
|
||||
return
|
||||
}
|
||||
if !isText {
|
||||
// Not a text file, skip content extraction
|
||||
return
|
||||
}
|
||||
content, err := getContent(info.RealPath)
|
||||
if err != nil {
|
||||
logger.Debugf("could not get content for file: "+info.RealPath, info.Name, err)
|
||||
@@ -295,13 +325,8 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
service = ffmpeg.NewFFmpegService(5, false, "")
|
||||
}
|
||||
if service != nil {
|
||||
startTime := time.Now()
|
||||
if duration, err := service.GetMediaDuration(ctx, realPath); err == nil {
|
||||
item.Metadata.Duration = int(duration)
|
||||
elapsed := time.Since(startTime)
|
||||
if elapsed > 100*time.Millisecond {
|
||||
logger.Debugf("Duration extraction took %v for file: %s", elapsed, item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,14 +371,14 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteFiles(source, absPath string, absDirPath string, isDir bool) error {
|
||||
func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
|
||||
if !index.Config.DisableIndexing {
|
||||
indexPath := index.MakeIndexPath(absPath)
|
||||
indexPath := index.MakeIndexPath(absPath, isDir)
|
||||
|
||||
// Perform the physical deletion
|
||||
err := os.RemoveAll(absPath)
|
||||
@@ -366,18 +391,14 @@ func DeleteFiles(source, absPath string, absDirPath string, isDir bool) error {
|
||||
indexing.IsDirCache.Delete(absPath + ":isdir")
|
||||
|
||||
// Remove metadata from index
|
||||
if isDir {
|
||||
// Recursively remove directory and all subdirectories from index
|
||||
index.DeleteMetadata(indexPath, true, true)
|
||||
} else {
|
||||
// Remove file from parent's file list
|
||||
index.DeleteMetadata(indexPath, false, false)
|
||||
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
|
||||
if !deleteSuccess {
|
||||
logger.Errorf("Failed to delete metadata from index for %s, but filesystem deletion succeeded", indexPath)
|
||||
}
|
||||
|
||||
// Refresh the parent directory to recalculate sizes and update counts
|
||||
// This will traverse up the tree and update all parent sizes correctly
|
||||
refreshConfig := utils.FileOptions{
|
||||
Path: index.MakeIndexPath(absDirPath),
|
||||
Path: index.MakeIndexPath(filepath.Dir(absPath), true),
|
||||
IsDir: true,
|
||||
}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
@@ -397,15 +418,20 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
return nil
|
||||
}
|
||||
// Always normalize path using MakeIndexPath
|
||||
path = idx.MakeIndexPath(path)
|
||||
path = idx.MakeIndexPath(path, isDir)
|
||||
|
||||
// MakeIndexPath always adds trailing slash, but for files we need to remove it
|
||||
if !isDir {
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
// Check if path is a symlink
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
isSymlink := false
|
||||
if realPath != "" {
|
||||
if fileInfo, err := os.Lstat(realPath); err == nil {
|
||||
isSymlink = fileInfo.Mode()&os.ModeSymlink != 0
|
||||
}
|
||||
}
|
||||
|
||||
// Skip indexing for viewable paths (viewable: true means don't index, just allow FS access)
|
||||
if idx.IsViewable(isDir, path) {
|
||||
// Skip indexing only for paths that are explicitly excluded (not viewable and should be skipped)
|
||||
hidden := indexing.IsHidden(filepath.Join(idx.Path, path))
|
||||
if idx.ShouldSkip(isDir, path, hidden, isSymlink, false) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -427,6 +453,10 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
}
|
||||
|
||||
err := idx.RefreshFileInfo(utils.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
|
||||
if err != nil {
|
||||
logger.Errorf("RefreshFileInfo failed: %v", err)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -481,7 +511,7 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
}
|
||||
|
||||
// Prepare paths for index operations
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc)
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc, isSrcDir)
|
||||
srcParentPath := filepath.Dir(realsrc)
|
||||
|
||||
// Perform the physical move
|
||||
@@ -491,37 +521,52 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
}
|
||||
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !srcIdx.Config.DisableIndexing {
|
||||
if isSrcDir {
|
||||
srcIdx.DeleteMetadata(srcIndexPath, true, true)
|
||||
} else {
|
||||
srcIdx.DeleteMetadata(srcIndexPath, false, false)
|
||||
}
|
||||
go RefreshIndex(sourceIndex, srcParentPath, true, false) //nolint:errcheck
|
||||
go func() {
|
||||
if isSrcDir {
|
||||
srcIdx.DeleteMetadata(srcIndexPath, true, true)
|
||||
} else {
|
||||
srcIdx.DeleteMetadata(srcIndexPath, false, false)
|
||||
}
|
||||
if err := RefreshIndex(sourceIndex, srcParentPath, true, false); err != nil {
|
||||
logger.Errorf("Failed to refresh source parent directory %s after move: %v", srcParentPath, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Handle DESTINATION indexing
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !dstIdx.Config.DisableIndexing {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
RefreshIndex(destIndex, realdst, true, true) //nolint:errcheck
|
||||
// Recursively index the moved directory tree
|
||||
if err := RefreshIndex(destIndex, realdst, true, true); err != nil {
|
||||
logger.Errorf("Failed to index moved directory %s: %v", realdst, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh parent to update its size
|
||||
parentDir := filepath.Dir(realdst)
|
||||
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
if err := RefreshIndex(destIndex, parentDir, true, false); err != nil {
|
||||
logger.Errorf("Failed to refresh destination parent %s: %v", parentDir, err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// For files, refresh parent directory
|
||||
parentDir := filepath.Dir(realdst)
|
||||
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// Use backend source paths to match how shares are stored
|
||||
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.Path, dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
|
||||
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc, isSrcDir), dstIdx.Path, dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
|
||||
|
||||
// Update access rules for the moved path
|
||||
if a != nil {
|
||||
// If moving within the same source, update the rules
|
||||
if srcIdx.Path == dstIdx.Path {
|
||||
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
|
||||
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc, isSrcDir), dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
|
||||
}
|
||||
// Cross-source moves don't preserve access rules (they're source-specific)
|
||||
}
|
||||
@@ -537,37 +582,51 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
|
||||
// Validate the copy operation before executing
|
||||
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
|
||||
logger.Errorf("[COPY] Copy validation failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Perform the physical copy
|
||||
err := fileutils.CopyFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
logger.Errorf("[COPY] Physical copy failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// For copy operations:
|
||||
// 1. Shallow refresh of source (just to update access times if needed)
|
||||
// 2. Recursively index the destination to capture the entire copied tree
|
||||
|
||||
// Refresh source (parent directory if it's a file)
|
||||
srcRefreshPath := realsrc
|
||||
srcRefreshIsDir := isSrcDir
|
||||
if !isSrcDir {
|
||||
srcRefreshPath = filepath.Dir(realsrc)
|
||||
srcRefreshIsDir = true
|
||||
// Refresh source (non-recursive, just metadata)
|
||||
srcIdx := indexing.GetIndex(sourceIndex)
|
||||
if srcIdx != nil && !srcIdx.Config.DisableIndexing {
|
||||
srcRefreshPath := realsrc
|
||||
if !isSrcDir {
|
||||
srcRefreshPath = filepath.Dir(realsrc)
|
||||
}
|
||||
go RefreshIndex(sourceIndex, srcRefreshPath, true, false) //nolint:errcheck
|
||||
}
|
||||
|
||||
go RefreshIndex(sourceIndex, srcRefreshPath, srcRefreshIsDir, false) //nolint:errcheck
|
||||
// Refresh destination (RECURSIVE for directories to capture full tree)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
dstIdx := indexing.GetIndex(destIndex)
|
||||
if dstIdx != nil && !dstIdx.Config.DisableIndexing {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// Recursively index the copied directory tree
|
||||
if err := RefreshIndex(destIndex, realdst, true, true); err != nil {
|
||||
logger.Errorf("[COPY] Failed to index copied directory %s: %v", realdst, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh destination (parent directory if it's a file)
|
||||
dstRefreshPath := realdst
|
||||
if !isSrcDir {
|
||||
// If copying a file (regardless of destination), refresh the parent directory
|
||||
dstRefreshPath = filepath.Dir(realdst)
|
||||
// Refresh parent to update its size
|
||||
parentDir := filepath.Dir(realdst)
|
||||
if err := RefreshIndex(destIndex, parentDir, true, false); err != nil {
|
||||
logger.Errorf("[COPY] Failed to refresh parent %s: %v", parentDir, err)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
// For files, refresh parent directory
|
||||
dstParent := filepath.Dir(realdst)
|
||||
go RefreshIndex(destIndex, dstParent, true, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
go RefreshIndex(destIndex, dstRefreshPath, true, true) //nolint:errcheck
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -603,7 +662,21 @@ func WriteDirectory(opts utils.FileOptions) error {
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
}
|
||||
|
||||
return RefreshIndex(idx.Name, opts.Path, true, true)
|
||||
// Refresh the directory itself recursively
|
||||
err = RefreshIndex(idx.Name, opts.Path, true, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh parent directory to update its size
|
||||
parentPath := filepath.Dir(opts.Path)
|
||||
if parentPath != "." && parentPath != "/" && parentPath != opts.Path {
|
||||
if err := RefreshIndex(idx.Name, parentPath, true, false); err != nil {
|
||||
logger.Debugf("Could not refresh parent directory %s: %v", parentPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteFile(source, path string, in io.Reader) error {
|
||||
@@ -655,7 +728,21 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
}
|
||||
|
||||
return RefreshIndex(source, path, false, false)
|
||||
// Refresh the file itself
|
||||
err = RefreshIndex(source, path, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh parent directory to update its size
|
||||
parentPath := filepath.Dir(path)
|
||||
if parentPath != "." && parentPath != "/" && parentPath != path {
|
||||
if err := RefreshIndex(source, parentPath, true, false); err != nil {
|
||||
logger.Debugf("Could not refresh parent directory %s: %v", parentPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getContent reads and returns the file content if it's considered an editable text file.
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
|
||||
== Running benchmark ==
|
||||
/usr/local/go/bin/go
|
||||
? github.com/gtsteffaniak/filebrowser/backend [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.005s
|
||||
2025/11/07 17:48:07 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:07 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:07 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:07 [ERROR] Root scanner error: open /tmp/filebrowser_test3280924123/: no such file or directory
|
||||
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 0.006s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.002s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/cmd 0.014s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/common/settings
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
10 374600 ns/op 192030 B/op 3237 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.139s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.004s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/access 0.287s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/crud [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt 0.842s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.003s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/docs [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/events [no test files]
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/ffmpeg
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
BenchmarkGetOrientationFilter-8 10 63.00 ns/op 0 B/op 0 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/ffmpeg 0.006s
|
||||
2025/11/07 17:48:08 [DEBUG] Saving user [admin] changepass: true
|
||||
2025/11/07 17:48:08 [DEBUG] Saving user [non-admin] changepass: true
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/http 0.248s
|
||||
2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/indexing
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
BenchmarkFillIndex-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
10 3611835 ns/op 323676 B/op 1655 allocs/op
|
||||
2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
BenchmarkSearchAllIndexes-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
10 784497 ns/op 97700 B/op 1061 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/indexing 0.077s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.006s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.130s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
|
||||
+36
-5
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "net/http/pprof"
|
||||
|
||||
@@ -96,7 +97,15 @@ func StartFilebrowser() {
|
||||
logger.Info(database)
|
||||
logger.Infof("Sources : %v", sourceList)
|
||||
logger.Debugf("Using Embedded FS : %v", settings.Env.EmbeddedFs)
|
||||
|
||||
walModeStr := "OFF"
|
||||
if settings.Config.Server.IndexSqlConfig.WalMode {
|
||||
walModeStr = "WAL"
|
||||
}
|
||||
logger.Infof("SQL Journal Mode : %v", walModeStr)
|
||||
if settings.Config.Server.CacheDirCleanup {
|
||||
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
serverConfig := settings.Config.Server
|
||||
swagInfo := docs.SwaggerInfo
|
||||
swagInfo.BasePath = serverConfig.BaseURL
|
||||
@@ -105,6 +114,17 @@ func StartFilebrowser() {
|
||||
if len(settings.Config.Server.SourceMap) == 0 {
|
||||
logger.Fatal("No sources configured, exiting...")
|
||||
}
|
||||
|
||||
// Initialize shared index database before starting HTTP service
|
||||
if err := indexing.InitializeIndexDB(); err != nil {
|
||||
logger.Fatalf("Failed to initialize index database: %v", err)
|
||||
}
|
||||
|
||||
// Set indexing storage for persistence
|
||||
if store != nil && store.Indexing != nil {
|
||||
indexing.SetIndexingStorage(store.Indexing)
|
||||
}
|
||||
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
go indexing.Initialize(source, false)
|
||||
}
|
||||
@@ -127,12 +147,23 @@ func StartFilebrowser() {
|
||||
case <-done:
|
||||
logger.Info("Server stopped unexpectedly. Shutting down...")
|
||||
}
|
||||
if !*settings.Config.Server.CacheDirCleanup {
|
||||
|
||||
// Stop all indexing scanners before closing the database
|
||||
indexing.StopAllScanners()
|
||||
|
||||
// Give scanners a moment to finish their current scan operations
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// cleanup temp databases
|
||||
indexDB := indexing.GetIndexDB()
|
||||
if indexDB != nil {
|
||||
indexDB.Close()
|
||||
}
|
||||
if settings.Config.Server.CacheDirCleanup {
|
||||
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
|
||||
<-shutdownComplete // Ensure we don't exit prematurely
|
||||
// Wait for the server to stop
|
||||
<-shutdownComplete
|
||||
logger.Info("Shutdown complete.")
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ func validateUserInfo(newDB bool) {
|
||||
if updateShowFirstLogin(user) {
|
||||
updateUser = true
|
||||
}
|
||||
if updateSidebarLinks(user) {
|
||||
updateUser = true
|
||||
}
|
||||
adminUser := settings.Config.Auth.AdminUsername
|
||||
adminPass := settings.Config.Auth.AdminPassword
|
||||
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
|
||||
@@ -186,3 +189,60 @@ func updatePreviewSettings(user *users.User) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// updateSidebarLinks checks if user has stale source links and rebuilds them if needed
|
||||
func updateSidebarLinks(user *users.User) bool {
|
||||
// Count source links and check if any are still valid
|
||||
sourceLinksCount := 0
|
||||
validSourceLinksCount := 0
|
||||
|
||||
for _, link := range user.SidebarLinks {
|
||||
if link.Category == "source" {
|
||||
sourceLinksCount++
|
||||
// Check if this source still exists
|
||||
if link.SourceName != "" {
|
||||
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
|
||||
validSourceLinksCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If user has no source links, don't update anything
|
||||
if sourceLinksCount == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// If user has source links but NONE are valid, rebuild from their scopes
|
||||
if validSourceLinksCount == 0 {
|
||||
logger.Infof("User %s has %d stale source links, rebuilding from scopes", user.Username, sourceLinksCount)
|
||||
|
||||
// Remove all existing source links
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if link.Category != "source" {
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new source links based on user's scopes
|
||||
for _, scope := range user.Scopes {
|
||||
if source, ok := settings.Config.Server.SourceMap[scope.Name]; ok {
|
||||
// User has access to this source, add it to sidebar
|
||||
newLinks = append(newLinks, users.SidebarLink{
|
||||
Name: source.Name,
|
||||
Category: "source",
|
||||
Target: "/",
|
||||
Icon: "",
|
||||
SourceName: source.Path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
user.SidebarLinks = newLinks
|
||||
return true
|
||||
}
|
||||
|
||||
// User has at least one valid source link, no update needed
|
||||
return false
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func Initialize(configFile string) {
|
||||
setupServer()
|
||||
setupAuth(false)
|
||||
setupSources(false)
|
||||
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
|
||||
setupUrls()
|
||||
setupFrontend(false)
|
||||
setupMedia()
|
||||
@@ -61,6 +62,11 @@ func setupServer() {
|
||||
if Config.Server.ListenAddress == "" {
|
||||
Config.Server.ListenAddress = "0.0.0.0"
|
||||
}
|
||||
// Check environment variable first (overrides config file)
|
||||
if os.Getenv("FILEBROWSER_SQL_WAL") == "true" {
|
||||
Config.Server.IndexSqlConfig.WalMode = true
|
||||
}
|
||||
// WalMode is false by default (OFF journaling)
|
||||
}
|
||||
|
||||
func setupEnv() {
|
||||
@@ -649,7 +655,12 @@ func setDefaults(generate bool) Settings {
|
||||
NameToSource: map[string]*Source{},
|
||||
MaxArchiveSizeGB: 50,
|
||||
CacheDir: "tmp",
|
||||
CacheDirCleanup: boolPtr(true),
|
||||
IndexSqlConfig: IndexSqlConfig{
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
},
|
||||
Filesystem: Filesystem{
|
||||
CreateFilePermission: "644",
|
||||
CreateDirectoryPermission: "755",
|
||||
@@ -699,8 +710,8 @@ func setDefaults(generate bool) Settings {
|
||||
Folder: boolPtr(true),
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
ChunkSize: 10, // 10MB
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -823,28 +834,70 @@ func loadLoginIcon() {
|
||||
// setConditionalsMap builds optimized map structures from conditional rules for O(1) lookups
|
||||
func setConditionals(config *Source) {
|
||||
|
||||
// Merge rules from both old format (Conditionals.ItemRules) and new format (Rules)
|
||||
rules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...)
|
||||
|
||||
// Initialize the maps structure (only exact match maps for Names)
|
||||
resolved := ResolvedRulesConfig{
|
||||
FileNames: make(map[string]ConditionalRule),
|
||||
FolderNames: make(map[string]ConditionalRule),
|
||||
FilePaths: make(map[string]ConditionalRule),
|
||||
FolderPaths: make(map[string]ConditionalRule),
|
||||
FileEndsWith: make([]ConditionalRule, 0),
|
||||
FolderEndsWith: make([]ConditionalRule, 0),
|
||||
FileStartsWith: make([]ConditionalRule, 0),
|
||||
FolderStartsWith: make([]ConditionalRule, 0),
|
||||
NeverWatchPaths: make(map[string]struct{}),
|
||||
IncludeRootItems: make(map[string]struct{}),
|
||||
IgnoreAllHidden: false,
|
||||
IgnoreAllZeroSizeFolders: false,
|
||||
IgnoreAllSymlinks: false,
|
||||
IndexingDisabled: false,
|
||||
}
|
||||
|
||||
// backwards compatibility
|
||||
if config.Config.Conditionals.Hidden {
|
||||
logger.Warning("conditionals.hidden is deprecated, use conditionals.ignoreHidden instead")
|
||||
config.Config.Conditionals.IgnoreHidden = true
|
||||
logger.Warning("source.conditionals.hidden is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllHidden = true
|
||||
}
|
||||
// Backwards compatibility: if old format fields are set, treat as global rules
|
||||
if config.Config.Conditionals.IgnoreHidden {
|
||||
logger.Warning("source.conditionals.ignoreHidden is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllHidden = true
|
||||
}
|
||||
if config.Config.DisableIndexing {
|
||||
logger.Warning("source.disableIndexing is deprecated, use source.rules instead")
|
||||
resolved.IndexingDisabled = true
|
||||
}
|
||||
|
||||
rules := config.Config.Conditionals.ItemRules
|
||||
// Initialize the maps structure (only exact match maps for Names)
|
||||
resolved := &ResolvedConditionalsConfig{
|
||||
FileNames: make(map[string]ConditionalIndexConfig),
|
||||
FolderNames: make(map[string]ConditionalIndexConfig),
|
||||
FilePaths: make(map[string]ConditionalIndexConfig),
|
||||
FolderPaths: make(map[string]ConditionalIndexConfig),
|
||||
FileEndsWith: make([]ConditionalIndexConfig, 0),
|
||||
FolderEndsWith: make([]ConditionalIndexConfig, 0),
|
||||
FileStartsWith: make([]ConditionalIndexConfig, 0),
|
||||
FolderStartsWith: make([]ConditionalIndexConfig, 0),
|
||||
NeverWatchPaths: make(map[string]struct{}),
|
||||
IncludeRootItems: make(map[string]struct{}),
|
||||
if config.Config.Conditionals.ZeroSizeFolders {
|
||||
logger.Warning("source.conditionals.zeroSizeFolders is deprecated, use source.rules instead")
|
||||
resolved.IgnoreAllZeroSizeFolders = true
|
||||
}
|
||||
|
||||
// Process all rules and infer global flags from root-level rules
|
||||
for _, rule := range rules {
|
||||
// Check if this is a root-level rule (folderPath == "/")
|
||||
// Root-level rules with ignoreHidden/ignoreZeroSizeFolders/viewable set global flags
|
||||
isRootLevelRule := rule.FolderPath == "/"
|
||||
|
||||
// Infer global flags from root-level rules
|
||||
if isRootLevelRule {
|
||||
if rule.IgnoreHidden {
|
||||
resolved.IgnoreAllHidden = true
|
||||
}
|
||||
if rule.IgnoreSymlinks {
|
||||
resolved.IgnoreAllSymlinks = true
|
||||
}
|
||||
if rule.IgnoreZeroSizeFolders {
|
||||
resolved.IgnoreAllZeroSizeFolders = true
|
||||
}
|
||||
if rule.Viewable {
|
||||
resolved.IndexingDisabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build optimized lookup structures
|
||||
if rule.FileEndsWith != "" {
|
||||
resolved.FileEndsWith = append(resolved.FileEndsWith, rule)
|
||||
}
|
||||
@@ -882,7 +935,7 @@ func setConditionals(config *Source) {
|
||||
resolved.FolderNames[rule.FolderName] = rule
|
||||
}
|
||||
}
|
||||
config.Config.ResolvedConditionals = resolved
|
||||
config.Config.ResolvedRules = resolved
|
||||
}
|
||||
|
||||
func modifyExcludeInclude(config *Source) {
|
||||
@@ -914,20 +967,40 @@ func modifyExcludeInclude(config *Source) {
|
||||
return strings.Trim(value, "/")
|
||||
}
|
||||
|
||||
// Normalize []ConditionalIndexConfig slices for full paths
|
||||
for i, rule := range config.Config.Conditionals.ItemRules {
|
||||
// Normalize []ConditionalRule slices for full paths
|
||||
// Handle both old format (Conditionals.ItemRules) and new format (Rules)
|
||||
allRules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...)
|
||||
|
||||
for i, rule := range allRules {
|
||||
// normalize full paths
|
||||
config.Config.Conditionals.ItemRules[i].FilePath = normalizeFullPath(rule.FilePath, true)
|
||||
config.Config.Conditionals.ItemRules[i].FolderPath = normalizeFullPath(rule.FolderPath, true)
|
||||
config.Config.Conditionals.ItemRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true)
|
||||
config.Config.Conditionals.ItemRules[i].IncludeRootItem = normalizeFullPath(rule.IncludeRootItem, true)
|
||||
allRules[i].FilePath = normalizeFullPath(rule.FilePath, true)
|
||||
// FolderPath gets trailing slash for proper prefix matching
|
||||
if rule.FolderPath != "" {
|
||||
normalized := normalizeFullPath(rule.FolderPath, true)
|
||||
if normalized != "/" && !strings.HasSuffix(normalized, "/") {
|
||||
normalized = normalized + "/"
|
||||
}
|
||||
allRules[i].FolderPath = normalized
|
||||
}
|
||||
allRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true)
|
||||
if rule.IncludeRootItem != "" {
|
||||
normalized := normalizeFullPath(rule.IncludeRootItem, true)
|
||||
if normalized != "/" && !strings.HasSuffix(normalized, "/") {
|
||||
normalized = normalized + "/"
|
||||
}
|
||||
allRules[i].IncludeRootItem = normalized
|
||||
}
|
||||
|
||||
// normalize names
|
||||
config.Config.Conditionals.ItemRules[i].FileNames = normalizeName(rule.FileNames)
|
||||
config.Config.Conditionals.ItemRules[i].FolderNames = normalizeName(rule.FolderNames)
|
||||
config.Config.Conditionals.ItemRules[i].FileName = normalizeName(rule.FileName)
|
||||
config.Config.Conditionals.ItemRules[i].FolderName = normalizeName(rule.FolderName)
|
||||
allRules[i].FileNames = normalizeName(rule.FileNames)
|
||||
allRules[i].FolderNames = normalizeName(rule.FolderNames)
|
||||
allRules[i].FileName = normalizeName(rule.FileName)
|
||||
allRules[i].FolderName = normalizeName(rule.FolderName)
|
||||
}
|
||||
|
||||
// Update the original slices with normalized values
|
||||
itemRulesLen := len(config.Config.Conditionals.ItemRules)
|
||||
config.Config.Conditionals.ItemRules = allRules[:itemRulesLen]
|
||||
config.Config.Rules = allRules[itemRulesLen:]
|
||||
|
||||
}
|
||||
@@ -1,166 +1,60 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
|
||||
if len(scopes) == 0 {
|
||||
return Config.UserDefaults.DefaultScopes, nil
|
||||
}
|
||||
newScopes := []users.SourceScope{}
|
||||
for _, scope := range scopes {
|
||||
|
||||
// first check if its already a path name and keep it
|
||||
source, ok := Config.Server.SourceMap[scope.Name]
|
||||
if ok {
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.Config.DefaultUserScope
|
||||
}
|
||||
if !strings.HasPrefix(scope.Scope, "/") {
|
||||
scope.Scope = "/" + scope.Scope
|
||||
}
|
||||
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
|
||||
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
|
||||
}
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// check if its the name of a source and convert it to a path
|
||||
source, ok = Config.Server.NameToSource[scope.Name]
|
||||
// InitializeUserResolvers sets up the global resolvers and config providers in the users package
|
||||
// This should be called once during application initialization
|
||||
func InitializeUserResolvers() {
|
||||
// Set up the source name resolver
|
||||
users.SetSourceNameResolver(func(sourceName string) (string, error) {
|
||||
source, ok := Config.Server.NameToSource[sourceName]
|
||||
if !ok {
|
||||
// source might no longer be configured
|
||||
continue
|
||||
logger.Debug("Could not get scope from source name: ", sourceName)
|
||||
return "", nil
|
||||
}
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.Config.DefaultUserScope
|
||||
}
|
||||
if !strings.HasPrefix(scope.Scope, "/") {
|
||||
scope.Scope = "/" + scope.Scope
|
||||
}
|
||||
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
|
||||
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
|
||||
}
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
return newScopes, nil
|
||||
}
|
||||
return source.Path, nil
|
||||
})
|
||||
|
||||
func ConvertToFrontendScopes(scopes []users.SourceScope) []users.SourceScope {
|
||||
newScopes := []users.SourceScope{}
|
||||
for _, scope := range scopes {
|
||||
if source, ok := Config.Server.SourceMap[scope.Name]; ok {
|
||||
// Replace scope.Name with source.Path while keeping the same Scope value
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Name,
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
return newScopes
|
||||
}
|
||||
|
||||
func ConvertToFrontendSidebarLinks(links []users.SidebarLink) []users.SidebarLink {
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range links {
|
||||
// For source links, validate that the source still exists
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
logger.Warningf("source link missing sourceName: %v", link.Name)
|
||||
continue
|
||||
}
|
||||
// Check if source exists
|
||||
sourceInfo, ok := Config.Server.SourceMap[link.SourceName]
|
||||
// Set up the source config provider
|
||||
users.SetSourceConfig(&users.SourceConfigProvider{
|
||||
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
|
||||
source, ok := Config.Server.SourceMap[path]
|
||||
if !ok {
|
||||
logger.Warningf("source not found: %v (link name: %v)", link.SourceName, link.Name)
|
||||
continue
|
||||
return users.SourceInfo{}, false
|
||||
}
|
||||
link.SourceName = sourceInfo.Name
|
||||
}
|
||||
// For share links, just pass through (shares are validated separately)
|
||||
// For all other links, pass through as-is
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks
|
||||
}
|
||||
|
||||
func ConvertToBackendSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, error) {
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range links {
|
||||
// For source links, validate that the source exists using SourceName
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
|
||||
}
|
||||
// Validate source exists
|
||||
sourceInfo, ok := Config.Server.NameToSource[link.SourceName]
|
||||
return users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
}, true
|
||||
},
|
||||
GetSourceByName: func(name string) (users.SourceInfo, bool) {
|
||||
source, ok := Config.Server.NameToSource[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
|
||||
return users.SourceInfo{}, false
|
||||
}
|
||||
link.SourceName = sourceInfo.Path // if name chages keep link alive
|
||||
}
|
||||
// Store the link as-is with all fields preserved
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks, nil
|
||||
}
|
||||
|
||||
func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
|
||||
source, ok := Config.Server.NameToSource[sourceName]
|
||||
if !ok {
|
||||
logger.Debug("Could not get scope from source name: ", sourceName)
|
||||
return "", fmt.Errorf("source with name not found %v", sourceName)
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == source.Path {
|
||||
return scope.Scope, nil
|
||||
}
|
||||
}
|
||||
logger.Debugf("scope not found for source %v", sourceName)
|
||||
return "", fmt.Errorf("scope not found for source %v", sourceName)
|
||||
}
|
||||
|
||||
func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (string, error) {
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return scope.Scope, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("scope not found for source %v", sourcePath)
|
||||
}
|
||||
|
||||
// assumes backend style scopes
|
||||
func GetSources(u *users.User) []string {
|
||||
sources := []string{}
|
||||
|
||||
// preserves order of sources
|
||||
for _, source := range Config.Server.Sources {
|
||||
_, err := GetScopeFromSourcePath(u.Scopes, source.Path)
|
||||
if err != nil {
|
||||
logger.Warningf("could not get scope for source %v: %v", source.Path, err)
|
||||
continue
|
||||
}
|
||||
sources = append(sources, source.Name)
|
||||
}
|
||||
return sources
|
||||
return users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
}, true
|
||||
},
|
||||
GetAllSources: func() []users.SourceInfo {
|
||||
sources := make([]users.SourceInfo, 0, len(Config.Server.Sources))
|
||||
for _, source := range Config.Server.Sources {
|
||||
sources = append(sources, users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
return sources
|
||||
},
|
||||
GetDefaultScopes: func() []users.SourceScope {
|
||||
return Config.UserDefaults.DefaultScopes
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -708,7 +708,7 @@ func GenerateYaml() {
|
||||
Path: ".",
|
||||
Config: SourceConfig{
|
||||
Conditionals: ConditionalFilter{
|
||||
ItemRules: []ConditionalIndexConfig{
|
||||
ItemRules: []ConditionalRule{
|
||||
{}, // Add empty sample rule to show all fields in generated YAML
|
||||
},
|
||||
},
|
||||
|
||||
@@ -39,27 +39,29 @@ type Environment struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
|
||||
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
|
||||
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
|
||||
Socket string `json:"socket"` // socket to listen on
|
||||
TLSKey string `json:"tlsKey"` // path to TLS key
|
||||
TLSCert string `json:"tlsCert"` // path to TLS cert
|
||||
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
|
||||
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
|
||||
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
|
||||
Port int `json:"port"` // port to listen on
|
||||
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
|
||||
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
|
||||
Logging []LogConfig `json:"logging" yaml:"logging"`
|
||||
Database string `json:"database"` // path to the database file
|
||||
Sources []*Source `json:"sources" validate:"required,dive"`
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup *bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: true)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
|
||||
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
|
||||
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
|
||||
Socket string `json:"socket"` // socket to listen on
|
||||
TLSKey string `json:"tlsKey"` // path to TLS key
|
||||
TLSCert string `json:"tlsCert"` // path to TLS cert
|
||||
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
|
||||
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
|
||||
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
|
||||
Port int `json:"port"` // port to listen on
|
||||
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
|
||||
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
|
||||
Logging []LogConfig `json:"logging" yaml:"logging"`
|
||||
Database string `json:"database"` // path to the database file
|
||||
Sources []*Source `json:"sources" validate:"required,dive"`
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
|
||||
|
||||
// not exposed to config
|
||||
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
|
||||
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
|
||||
@@ -70,6 +72,13 @@ type Filesystem struct {
|
||||
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
|
||||
type Integrations struct {
|
||||
OnlyOffice OnlyOffice `json:"office" validate:"omitempty"`
|
||||
Media Media `json:"media" validate:"omitempty"`
|
||||
@@ -181,24 +190,26 @@ type SourceConfig struct {
|
||||
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
|
||||
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
|
||||
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
|
||||
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // (optional) not recommended: manual overide interval in minutes to re-index the source
|
||||
DisableIndexing bool `json:"disableIndexing,omitempty"` // (optional) not recommended: disable the indexing of this source
|
||||
Conditionals ConditionalFilter `json:"conditionals"` // conditional rules to apply when indexing to include/exclude certain items
|
||||
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // deprecated: create a rule with indexingIntervalMinutes to set the indexing interval for this source
|
||||
DisableIndexing bool `json:"disableIndexing,omitempty"` // deprecated: use indexingDisabled instead to disable the indexing of this source
|
||||
Conditionals ConditionalFilter `json:"conditionals"` // deprecated: use source.rules instead
|
||||
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
|
||||
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
|
||||
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
|
||||
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
// hidden but used internally - optimized map lookups for conditional rules
|
||||
ResolvedConditionals *ResolvedConditionalsConfig `json:"-"`
|
||||
ResolvedRules ResolvedRulesConfig `json:"-"`
|
||||
}
|
||||
|
||||
type ConditionalFilter struct {
|
||||
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead to exclude hidden files and folders.
|
||||
IgnoreHidden bool `json:"ignoreHidden"` // exclude hidden files and folders.
|
||||
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
|
||||
ItemRules []ConditionalIndexConfig `json:"rules"` // list of item rules to apply to specific paths
|
||||
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
|
||||
IgnoreHidden bool `json:"ignoreHidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
|
||||
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // deprecated: use ignoreZeroSizeFolders instead. eg, FolderPath: "/" and ignoreZeroSizeFolders: true will ignore folders with 0 size under the root folder.
|
||||
ItemRules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
|
||||
}
|
||||
|
||||
type ConditionalIndexConfig struct {
|
||||
type ConditionalRule struct {
|
||||
NeverWatchPath string `json:"neverWatchPath"` // index the folder in the first pass to get included in search, but never re-indexed.
|
||||
IncludeRootItem string `json:"includeRootItem"` // include only these items at root folder level
|
||||
FileStartsWith string `json:"fileStartsWith"` // (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
|
||||
@@ -212,29 +223,37 @@ type ConditionalIndexConfig struct {
|
||||
FileName string `json:"fileName"` // (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
|
||||
FolderName string `json:"folderName"` // (global) exclude folders that match these names. Eg. "folder" or "subfolder"
|
||||
|
||||
Viewable bool `json:"viewable"` // Enable viewing in UI but exclude from indexing
|
||||
Viewable bool `json:"viewable"` // Enable viewing in UI but exclude from indexing
|
||||
IgnoreHidden bool `json:"ignoreHidden"` // Excludes only hidden files and folders
|
||||
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // Excludes only folders with 0 size
|
||||
IgnoreSymlinks bool `json:"ignoreSymlinks"` // Excludes symbolic links
|
||||
}
|
||||
|
||||
// ConditionalMaps provides O(1) lookup performance for conditional rules
|
||||
// Maps are built from ConditionalFilter during initialization
|
||||
type ResolvedConditionalsConfig struct {
|
||||
type ResolvedRulesConfig struct {
|
||||
// Exact match maps - O(1) lookup (only for names, not StartsWith/EndsWith)
|
||||
FileNames map[string]ConditionalIndexConfig // key: file name
|
||||
FolderNames map[string]ConditionalIndexConfig // key: folder name
|
||||
FileNames map[string]ConditionalRule // key: file name
|
||||
FolderNames map[string]ConditionalRule // key: folder name
|
||||
|
||||
// exact match for paths
|
||||
FilePaths map[string]ConditionalIndexConfig // key: file path
|
||||
FolderPaths map[string]ConditionalIndexConfig // key: folder path
|
||||
FilePaths map[string]ConditionalRule // key: file path
|
||||
FolderPaths map[string]ConditionalRule // key: folder path
|
||||
|
||||
FileEndsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
|
||||
FolderEndsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
|
||||
FileStartsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
|
||||
FolderStartsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
|
||||
FileEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
FolderEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
FileStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
FolderStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
|
||||
// NeverWatch paths map - O(1) lookup for all paths with neverWatch: true
|
||||
// This replaces the old NeverWatchPaths slice
|
||||
NeverWatchPaths map[string]struct{} // key: full folder path
|
||||
IncludeRootItems map[string]struct{} // key: inclusive root item
|
||||
|
||||
IgnoreAllHidden bool // Excludes all hidden files and folders
|
||||
IgnoreAllZeroSizeFolders bool // Excludes all folders with 0 size
|
||||
IgnoreAllSymlinks bool // Excludes all symbolic links
|
||||
IndexingDisabled bool // Excludes all files and folders from indexing
|
||||
}
|
||||
|
||||
type Frontend struct {
|
||||
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
)
|
||||
@@ -44,12 +47,10 @@ func GetChecksum(fullPath, algo string) (string, error) {
|
||||
|
||||
// FileOptions are the options when getting a file info.
|
||||
type FileOptions struct {
|
||||
Username string // username for access control
|
||||
Path string // realpath
|
||||
Source string
|
||||
IsDir bool
|
||||
Expand bool
|
||||
ReadHeader bool
|
||||
Content bool
|
||||
Recursive bool // whether to recursively index directories
|
||||
Metadata bool // whether to get metadata
|
||||
@@ -58,3 +59,17 @@ type FileOptions struct {
|
||||
ShowHidden bool // whether to show hidden files (true = show, false = hide)
|
||||
FollowSymlinks bool // whether to follow symlinks
|
||||
}
|
||||
|
||||
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
|
||||
// Rule 1: Do Not Use User Input in File Paths (without validation)
|
||||
func SanitizeUserPath(userPath string) (string, error) {
|
||||
// Clean resolves . and .. segments
|
||||
clean := filepath.Clean(userPath)
|
||||
|
||||
// Reject absolute paths or attempts to escape scope
|
||||
if strings.HasPrefix(clean, "..") {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -130,6 +131,20 @@ func Ternary[T any](cond bool, vtrue, vfalse T) T {
|
||||
return vfalse
|
||||
}
|
||||
|
||||
// Clamp returns value clamped between min and max.
|
||||
// If value < min, returns min.
|
||||
// If value > max, returns max.
|
||||
// Otherwise, returns value.
|
||||
func Clamp[T cmp.Ordered](value, min, max T) T {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// NormalizeRulePath ensures directory paths have trailing slashes for consistent rule storage
|
||||
func AddTrailingSlashIfNotExists(indexPath string) string {
|
||||
// Root path stays as "/"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// IsTextFile checks if a file is likely a text file by examining its content.
|
||||
// It returns true if the file appears to be a valid text file, false otherwise.
|
||||
// This function performs various heuristics to detect text files:
|
||||
// - Valid UTF-8 encoding
|
||||
// - Limited null bytes
|
||||
// - Limited non-printable characters
|
||||
func IsTextFile(realPath string) (bool, error) {
|
||||
const headerSize = 4096
|
||||
// Thresholds for detecting binary-like content (these can be tuned)
|
||||
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
|
||||
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
|
||||
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
|
||||
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
|
||||
|
||||
// Open file
|
||||
f, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Read header
|
||||
headerBytes := make([]byte, headerSize)
|
||||
n, err := f.Read(headerBytes)
|
||||
if err != nil && err != io.EOF {
|
||||
return false, err
|
||||
}
|
||||
actualHeader := headerBytes[:n]
|
||||
|
||||
// --- Start of heuristic checks ---
|
||||
|
||||
if n > 0 {
|
||||
// Trim header to last complete UTF-8 rune to avoid false negatives
|
||||
// when the header read cuts off in the middle of a multi-byte sequence.
|
||||
// We decode runes from the end until we find a valid one, trimming
|
||||
// any incomplete sequences at the end.
|
||||
trimmedHeader := actualHeader
|
||||
for len(trimmedHeader) > 0 {
|
||||
lastRune, size := utf8.DecodeLastRune(trimmedHeader)
|
||||
if lastRune != utf8.RuneError {
|
||||
// Found a valid complete rune
|
||||
break
|
||||
}
|
||||
// RuneError occurred - this could be an incomplete sequence or invalid byte
|
||||
// Trim the last byte and try again
|
||||
if size == 1 && len(trimmedHeader) > 0 {
|
||||
trimmedHeader = trimmedHeader[:len(trimmedHeader)-1]
|
||||
} else {
|
||||
// Shouldn't happen, but break to avoid infinite loop
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Basic Check: Is the header valid UTF-8?
|
||||
// If not, it's unlikely an editable UTF-8 text file.
|
||||
// Use trimmed header to avoid false negatives from truncated sequences
|
||||
if len(trimmedHeader) > 0 && !utf8.Valid(trimmedHeader) {
|
||||
return false, nil // Not an error, just not the text file we want
|
||||
}
|
||||
|
||||
// 2. Check for excessive null bytes in the header
|
||||
nullCountInHeader := 0
|
||||
for _, b := range actualHeader {
|
||||
if b == 0x00 {
|
||||
nullCountInHeader++
|
||||
}
|
||||
}
|
||||
// Reject if too many nulls absolutely or relatively in the header
|
||||
if nullCountInHeader > 0 { // Only perform check if there are any nulls
|
||||
if nullCountInHeader > maxNullBytesInHeaderAbs ||
|
||||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
|
||||
return false, nil // Too many nulls in header
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for other non-text ASCII control characters in the header
|
||||
// (C0 controls excluding \t, \n, \r)
|
||||
for _, b := range actualHeader {
|
||||
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
|
||||
return false, nil // Found problematic control character
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now read the full file (original logic)
|
||||
content, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Handle empty file (empty files are considered text)
|
||||
if len(content) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
stringContent := string(content)
|
||||
|
||||
// 4. Final UTF-8 validation for the entire file
|
||||
// (This is crucial as the header might be fine, but the rest of the file isn't)
|
||||
if !utf8.ValidString(stringContent) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 5. Check for excessive null bytes in the entire file content
|
||||
if len(content) > 0 { // Check only for non-empty files
|
||||
totalNullCount := 0
|
||||
for _, b := range content {
|
||||
if b == 0x00 {
|
||||
totalNullCount++
|
||||
}
|
||||
}
|
||||
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
|
||||
return false, nil // Too many nulls in the entire file
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check for excessive non-printable runes in the entire file content
|
||||
// (Excluding tab, newline, carriage return, which are common in text files)
|
||||
if len(stringContent) > 0 { // Check only for non-empty strings
|
||||
nonPrintableRuneCount := 0
|
||||
totalRuneCount := 0
|
||||
for _, r := range stringContent {
|
||||
totalRuneCount++
|
||||
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
|
||||
// It excludes control characters. We explicitly allow \t, \n, \r.
|
||||
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
|
||||
nonPrintableRuneCount++
|
||||
}
|
||||
}
|
||||
|
||||
if totalRuneCount > 0 { // Avoid division by zero
|
||||
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
|
||||
return false, nil // Too many non-printable runes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The file has passed all checks and is considered editable text.
|
||||
return true, nil
|
||||
}
|
||||
@@ -38,17 +38,32 @@ func setupTestSources() {
|
||||
Path: "mnt/storage",
|
||||
Name: "storage",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false,
|
||||
DenyByDefault: false,
|
||||
DefaultUserScope: "/",
|
||||
},
|
||||
},
|
||||
"mnt/open": {
|
||||
Path: "mnt/open",
|
||||
Name: "open",
|
||||
Config: settings.SourceConfig{
|
||||
DenyByDefault: false,
|
||||
DenyByDefault: false,
|
||||
DefaultUserScope: "/",
|
||||
},
|
||||
},
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"storage": settings.Config.Server.SourceMap["mnt/storage"],
|
||||
"open": settings.Config.Server.SourceMap["mnt/open"],
|
||||
}
|
||||
settings.Config.Server.Sources = []*settings.Source{
|
||||
settings.Config.Server.SourceMap["mnt/storage"],
|
||||
settings.Config.Server.SourceMap["mnt/open"],
|
||||
}
|
||||
settings.Config.UserDefaults.DefaultScopes = []users.SourceScope{
|
||||
{Name: "mnt/storage", Scope: "/"},
|
||||
}
|
||||
// Initialize user package resolvers
|
||||
settings.InitializeUserResolvers()
|
||||
}
|
||||
|
||||
func TestPermitted_UserBlacklist(t *testing.T) {
|
||||
|
||||
@@ -19,7 +19,8 @@ func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, inde
|
||||
}
|
||||
|
||||
// Use standardized path with trailing slash for proper path construction
|
||||
parentPath := index.MakeIndexPath(response.Path)
|
||||
// response is an ExtendedFileInfo which represents a directory (has Folders and Files)
|
||||
parentPath := index.MakeIndexPath(response.Path, true)
|
||||
|
||||
// Check if user has access to any items
|
||||
if !s.HasAnyVisibleItems(index.Path, parentPath, allItemNames, username) && len(allItemNames) > 0 {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// IndexInfo stores persistent information about an index and its scanners
|
||||
type IndexInfo struct {
|
||||
// Index identifier - uses the real filesystem path (index.Path) as the key
|
||||
Path string `json:"path" storm:"id,index"`
|
||||
|
||||
// Source name (index.Name) for reference
|
||||
Source string `json:"source" storm:"index"`
|
||||
|
||||
// Index-level stats
|
||||
Complexity uint `json:"complexity"`
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
|
||||
// Scanner information - map of scanner path to scanner stats
|
||||
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
|
||||
}
|
||||
|
||||
// PersistedScannerInfo stores persistent information about a scanner
|
||||
type PersistedScannerInfo struct {
|
||||
Path string `json:"path"`
|
||||
Complexity uint `json:"complexity"`
|
||||
CurrentSchedule int `json:"currentSchedule"`
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
QuickScanTime int `json:"quickScanTime"`
|
||||
FullScanTime int `json:"fullScanTime"`
|
||||
LastScanned time.Time `json:"lastScanned"`
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
|
||||
)
|
||||
|
||||
// StorageBackend is the interface to implement for an indexing storage.
|
||||
type StorageBackend interface {
|
||||
All() ([]*IndexInfo, error)
|
||||
GetByPath(path string) (*IndexInfo, error)
|
||||
GetBySource(source string) ([]*IndexInfo, error)
|
||||
Save(info *IndexInfo) error
|
||||
Delete(path string) error
|
||||
}
|
||||
|
||||
// crudBackend implements crud.CrudBackend[IndexInfo] for indexing storage.
|
||||
type crudBackend struct {
|
||||
back StorageBackend
|
||||
}
|
||||
|
||||
func (c *crudBackend) GetByID(id any) (*IndexInfo, error) {
|
||||
path, ok := id.(string)
|
||||
if !ok {
|
||||
return nil, errors.ErrInvalidDataType
|
||||
}
|
||||
return c.back.GetByPath(path)
|
||||
}
|
||||
|
||||
func (c *crudBackend) GetAll() ([]*IndexInfo, error) {
|
||||
return c.back.All()
|
||||
}
|
||||
|
||||
func (c *crudBackend) Save(obj *IndexInfo) error {
|
||||
return c.back.Save(obj)
|
||||
}
|
||||
|
||||
func (c *crudBackend) DeleteByID(id any) error {
|
||||
path, ok := id.(string)
|
||||
if !ok {
|
||||
return errors.ErrInvalidDataType
|
||||
}
|
||||
return c.back.Delete(path)
|
||||
}
|
||||
|
||||
// Storage is an indexing storage using generics.
|
||||
type Storage struct {
|
||||
Generic *crud.Storage[IndexInfo]
|
||||
back StorageBackend
|
||||
indexCache map[string]*IndexInfo
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewStorage creates an indexing storage from a backend.
|
||||
func NewStorage(back StorageBackend) *Storage {
|
||||
return &Storage{
|
||||
Generic: crud.NewStorage[IndexInfo](&crudBackend{back: back}),
|
||||
back: back,
|
||||
indexCache: make(map[string]*IndexInfo),
|
||||
}
|
||||
}
|
||||
|
||||
// All wraps StorageBackend.All
|
||||
func (s *Storage) All() ([]*IndexInfo, error) {
|
||||
infos, err := s.back.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for i, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
if existing, ok := s.indexCache[info.Path]; ok && existing != nil {
|
||||
infos[i] = existing
|
||||
} else {
|
||||
s.indexCache[info.Path] = info
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// GetByPath wraps StorageBackend.GetByPath
|
||||
func (s *Storage) GetByPath(path string) (*IndexInfo, error) {
|
||||
// return stable in-memory pointer if available
|
||||
s.mu.RLock()
|
||||
if info, ok := s.indexCache[path]; ok && info != nil {
|
||||
s.mu.RUnlock()
|
||||
return info, nil
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
info, err := s.back.GetByPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.indexCache[path] = info
|
||||
s.mu.Unlock()
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// GetBySource wraps StorageBackend.GetBySource
|
||||
func (s *Storage) GetBySource(source string) ([]*IndexInfo, error) {
|
||||
infos, err := s.back.GetBySource(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for i, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
if existing, ok := s.indexCache[info.Path]; ok && existing != nil {
|
||||
infos[i] = existing
|
||||
} else {
|
||||
s.indexCache[info.Path] = info
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// Save wraps StorageBackend.Save
|
||||
func (s *Storage) Save(info *IndexInfo) error {
|
||||
if err := s.back.Save(info); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.indexCache[info.Path] = info
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete wraps StorageBackend.Delete
|
||||
func (s *Storage) Delete(path string) error {
|
||||
if err := s.back.Delete(path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.indexCache, path)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flush persists the current in-memory state of all indexes to the backing store.
|
||||
// Call during graceful shutdown to ensure DB matches memory.
|
||||
func (s *Storage) Flush() error {
|
||||
s.mu.RLock()
|
||||
infos := make([]*IndexInfo, 0, len(s.indexCache))
|
||||
for _, info := range s.indexCache {
|
||||
if info != nil {
|
||||
infos = append(infos, info)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
for _, info := range infos {
|
||||
if err := s.back.Save(info); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -26,6 +26,8 @@ type CommonShare struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
DownloadURL string `json:"downloadURL,omitempty"`
|
||||
ShareURL string `json:"shareURL,omitempty"`
|
||||
FaviconUrl string `json:"faviconUrl,omitempty"`
|
||||
BannerUrl string `json:"bannerUrl,omitempty"`
|
||||
DisableShareCard bool `json:"disableShareCard,omitempty"`
|
||||
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
|
||||
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
|
||||
@@ -41,7 +43,8 @@ type CommonShare struct {
|
||||
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
|
||||
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
HasPassword bool `json:"hasPassword,omitempty"`
|
||||
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
|
||||
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
|
||||
DisableLoginOption bool `json:"disableLoginOption,omitempty"` // disable login option in share (true = hide, false = show)
|
||||
}
|
||||
type CreateBody struct {
|
||||
CommonShare
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build cgosql
|
||||
// +build cgosql
|
||||
|
||||
package sql
|
||||
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const SqliteDriver = "sqlite3"
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !cgosql
|
||||
// +build !cgosql
|
||||
|
||||
package sql
|
||||
|
||||
import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const SqliteDriver = "sqlite"
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,103 @@
|
||||
package sql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SearchItem represents a single search result row from the database
|
||||
type SearchItem struct {
|
||||
Source string
|
||||
Path string
|
||||
Name string
|
||||
Size int64
|
||||
ModTime int64
|
||||
Type string
|
||||
IsDir bool
|
||||
HasPreview bool
|
||||
}
|
||||
|
||||
// SearchItems queries the database for items matching the search criteria for a single source.
|
||||
// Returns rows that can be iterated to scan search results.
|
||||
func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.Rows, error) {
|
||||
query := `
|
||||
SELECT path, name, size, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
WHERE source = ?
|
||||
`
|
||||
args := []interface{}{source}
|
||||
|
||||
// Apply scope filter
|
||||
if scope != "" {
|
||||
// Use GLOB for prefix matching which is supported by SQLite and efficient
|
||||
query += " AND path GLOB ?"
|
||||
args = append(args, scope+"*")
|
||||
}
|
||||
|
||||
if largest {
|
||||
query += " ORDER BY size DESC"
|
||||
}
|
||||
|
||||
return db.Query(query, args...)
|
||||
}
|
||||
|
||||
// SearchItemsMultiSource queries the database for items matching the search criteria across multiple sources.
|
||||
// Returns rows that can be iterated to scan search results.
|
||||
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool) (*sql.Rows, error) {
|
||||
if len(sources) == 0 {
|
||||
return nil, fmt.Errorf("at least one source is required")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT source, path, name, size, mod_time, type, is_dir, has_preview
|
||||
FROM index_items
|
||||
`
|
||||
args := []interface{}{}
|
||||
whereClauses := []string{}
|
||||
|
||||
// Filter by sources using IN clause
|
||||
if len(sources) == 1 {
|
||||
whereClauses = append(whereClauses, "source = ?")
|
||||
args = append(args, sources[0])
|
||||
} else {
|
||||
placeholders := make([]string, len(sources))
|
||||
for i, source := range sources {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, source)
|
||||
}
|
||||
whereClauses = append(whereClauses, "source IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
|
||||
// Apply scope filters - need OR conditions for each source+scope combination
|
||||
// Build conditions for each source: if it has a scope, filter by it; otherwise include all paths for that source
|
||||
scopeConditions := []string{}
|
||||
for _, source := range sources {
|
||||
scope, hasScope := sourceScopes[source]
|
||||
if hasScope && scope != "" {
|
||||
// Source has a scope - filter by it
|
||||
scopeConditions = append(scopeConditions, "(source = ? AND path GLOB ?)")
|
||||
args = append(args, source, scope+"*")
|
||||
} else {
|
||||
// Source has no scope or empty scope - include all paths for this source
|
||||
// The source IN clause already filters by source, so we just need to ensure
|
||||
// this source is included in the OR condition (which it already is via source IN)
|
||||
// We add a simple source check to make the OR condition work correctly
|
||||
scopeConditions = append(scopeConditions, "source = ?")
|
||||
args = append(args, source)
|
||||
}
|
||||
}
|
||||
if len(scopeConditions) > 0 {
|
||||
whereClauses = append(whereClauses, "("+strings.Join(scopeConditions, " OR ")+")")
|
||||
}
|
||||
|
||||
if len(whereClauses) > 0 {
|
||||
query += " WHERE " + strings.Join(whereClauses, " AND ")
|
||||
}
|
||||
|
||||
if largest {
|
||||
query += " ORDER BY size DESC"
|
||||
}
|
||||
|
||||
return db.Query(query, args...)
|
||||
}
|
||||
+302
-60
@@ -11,9 +11,24 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
_ "modernc.org/sqlite"
|
||||
// SQLite driver is imported in driver_cgo.go or driver_nocgo.go based on build tags
|
||||
)
|
||||
|
||||
const (
|
||||
// soft heap limit in bytes
|
||||
defaultSoftHeapLimitBytes = 32 * 1024 * 1024 // 32MB
|
||||
// Pager cache target expressed as number of 4KB pages (negative = pages).
|
||||
defaultCacheSizePages = -4096 // ~16MB
|
||||
)
|
||||
|
||||
func init() {
|
||||
if SqliteDriver == "sqlite3" {
|
||||
logger.Debugf("CGO SQLite driver initialized")
|
||||
} else {
|
||||
logger.Debugf("Default SQLite driver initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// TempDB manages a temporary SQLite database for operations that need
|
||||
// to stream large datasets without loading everything into memory.
|
||||
// This can be used by any part of the codebase that needs temporary SQLite storage.
|
||||
@@ -23,18 +38,23 @@ type TempDB struct {
|
||||
mu sync.Mutex
|
||||
startTime time.Time
|
||||
config *TempDBConfig
|
||||
BatchSize int // Exported batch size for bulk operations (from config)
|
||||
}
|
||||
|
||||
// TempDBConfig holds configuration options for temporary SQLite databases.
|
||||
type TempDBConfig struct {
|
||||
// BatchSize is the number of items to batch for bulk insert.
|
||||
// Default: 2500
|
||||
BatchSize int
|
||||
|
||||
// CacheSizeKB is the page cache size in KB. Negative values are in pages.
|
||||
// For one-time databases, a smaller cache (e.g., -2000 = ~8MB) is often sufficient.
|
||||
// Default: -2000 (approximately 8MB)
|
||||
// For one-time databases, a smaller cache (e.g., -4096 = ~16MB) is often sufficient.
|
||||
// Default: -4096 (approximately 16MB)
|
||||
CacheSizeKB int
|
||||
|
||||
// MmapSize is the memory-mapped I/O size in bytes. Set to 0 to disable mmap.
|
||||
// For databases that fit in RAM, set this larger than the expected DB size.
|
||||
// Default: 2GB (2147483648 bytes)
|
||||
// Default: 0 (disabled)
|
||||
MmapSize int64
|
||||
|
||||
// Synchronous controls the synchronous mode. OFF is fastest but less safe.
|
||||
@@ -47,20 +67,75 @@ type TempDBConfig struct {
|
||||
// Default: FILE (temporary tables stored on disk)
|
||||
TempStore string
|
||||
|
||||
// EnableLogging enables performance logging for debugging.
|
||||
// Default: false
|
||||
EnableLogging bool
|
||||
// JournalMode controls the journal mode. WAL is better for concurrent reads/writes.
|
||||
// DELETE is faster for write-heavy single-writer workloads.
|
||||
// Valid values: "DELETE", "WAL", "TRUNCATE", "PERSIST", "MEMORY", "OFF"
|
||||
// Default: WAL
|
||||
JournalMode string
|
||||
|
||||
// LockingMode controls the locking mode. EXCLUSIVE prevents other processes from accessing.
|
||||
// Valid values: "NORMAL", "EXCLUSIVE"
|
||||
// Default: NORMAL
|
||||
LockingMode string
|
||||
|
||||
// PageSize sets the database page size in bytes. Larger pages improve write performance.
|
||||
// Valid values: 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536
|
||||
// Default: 4096 (SQLite default)
|
||||
PageSize int
|
||||
|
||||
// AutoVacuum controls automatic vacuuming. NONE disables it for better write performance.
|
||||
// Valid values: "NONE", "FULL", "INCREMENTAL"
|
||||
// Default: NONE
|
||||
AutoVacuum string
|
||||
|
||||
// SoftHeapLimitBytes, when >0, sets PRAGMA soft_heap_limit so that SQLite
|
||||
// proactively frees caches before exceeding this many bytes. Zero disables it.
|
||||
SoftHeapLimitBytes int64
|
||||
|
||||
// HardHeapLimitBytes, when >0, sets PRAGMA hard_heap_limit as a hard cap on SQLite's heap.
|
||||
// Operations will fail if they would exceed this limit. Zero disables it.
|
||||
HardHeapLimitBytes int64
|
||||
|
||||
// CacheSpillThreshold sets PRAGMA cache_spill threshold in pages. When cache exceeds both
|
||||
// this threshold and cache_size, SQLite will spill dirty pages to disk.
|
||||
// Set to 0 to use default behavior (spill enabled but no specific threshold).
|
||||
// Default: 500 pages (~2MB) to trigger early spilling and reduce memory usage.
|
||||
CacheSpillThreshold int
|
||||
|
||||
// WalAutocheckpoint sets PRAGMA wal_autocheckpoint in pages. Controls how many pages
|
||||
// can accumulate in the WAL file before an automatic checkpoint.
|
||||
// Default: 1000 pages (~4MB). Larger values reduce checkpoint frequency.
|
||||
WalAutocheckpoint int
|
||||
|
||||
// JournalSizeLimit sets PRAGMA journal_size_limit in bytes. Limits the maximum size
|
||||
// of the WAL journal file. Set to -1 for no limit, 0 for default.
|
||||
// Default: 0 (uses SQLite default)
|
||||
JournalSizeLimit int64
|
||||
|
||||
// If empty, a temp file with random suffix will be created.
|
||||
// The file will not be deleted on Close() if this is set.
|
||||
PersistentFile string
|
||||
}
|
||||
|
||||
// mergeConfig merges the provided config with defaults, returning a new config.
|
||||
// If provided config is nil or empty, returns default config.
|
||||
func mergeConfig(provided *TempDBConfig) *TempDBConfig {
|
||||
defaults := &TempDBConfig{
|
||||
CacheSizeKB: -2000, // ~8MB, appropriate for one-time databases
|
||||
MmapSize: 2147483648, // 2GB
|
||||
Synchronous: "OFF",
|
||||
TempStore: "FILE", // Default to FILE, not MEMORY
|
||||
EnableLogging: false,
|
||||
BatchSize: 2500,
|
||||
CacheSizeKB: defaultCacheSizePages, // ~16MB pager cache
|
||||
MmapSize: 0, // Disable mmap to keep RSS predictable
|
||||
Synchronous: "OFF",
|
||||
TempStore: "FILE", // Default to FILE, not MEMORY
|
||||
JournalMode: "WAL", // WAL for better concurrency by default
|
||||
LockingMode: "NORMAL",
|
||||
PageSize: 4096, // SQLite default
|
||||
AutoVacuum: "NONE",
|
||||
// Soft heap limit keeps SQLite from retaining excessive pager cache memory.
|
||||
SoftHeapLimitBytes: defaultSoftHeapLimitBytes,
|
||||
// Hard heap limit provides a hard cap - operations fail if exceeded.
|
||||
HardHeapLimitBytes: 0, // Disabled by default, can be set per-database
|
||||
// Cache spill threshold triggers early spilling to reduce memory usage.
|
||||
CacheSpillThreshold: 500, // ~2MB threshold to trigger early spilling
|
||||
}
|
||||
|
||||
if provided == nil {
|
||||
@@ -70,6 +145,9 @@ func mergeConfig(provided *TempDBConfig) *TempDBConfig {
|
||||
merged := *defaults // Copy defaults
|
||||
|
||||
// Override with provided values if they are non-zero/non-empty
|
||||
if provided.BatchSize != 0 {
|
||||
merged.BatchSize = provided.BatchSize
|
||||
}
|
||||
if provided.CacheSizeKB != 0 {
|
||||
merged.CacheSizeKB = provided.CacheSizeKB
|
||||
}
|
||||
@@ -82,7 +160,32 @@ func mergeConfig(provided *TempDBConfig) *TempDBConfig {
|
||||
if provided.TempStore != "" {
|
||||
merged.TempStore = provided.TempStore
|
||||
}
|
||||
merged.EnableLogging = provided.EnableLogging
|
||||
if provided.JournalMode != "" {
|
||||
merged.JournalMode = provided.JournalMode
|
||||
}
|
||||
if provided.LockingMode != "" {
|
||||
merged.LockingMode = provided.LockingMode
|
||||
}
|
||||
if provided.PageSize != 0 {
|
||||
merged.PageSize = provided.PageSize
|
||||
}
|
||||
if provided.AutoVacuum != "" {
|
||||
merged.AutoVacuum = provided.AutoVacuum
|
||||
}
|
||||
if provided.HardHeapLimitBytes != 0 {
|
||||
merged.HardHeapLimitBytes = provided.HardHeapLimitBytes
|
||||
}
|
||||
if provided.CacheSpillThreshold != 0 {
|
||||
merged.CacheSpillThreshold = provided.CacheSpillThreshold
|
||||
}
|
||||
// SoftHeapLimitBytes: use provided value if non-zero (default is 32MB, but provided might be different)
|
||||
if provided.SoftHeapLimitBytes != 0 {
|
||||
merged.SoftHeapLimitBytes = provided.SoftHeapLimitBytes
|
||||
}
|
||||
// PersistentFile: always copy if non-empty (this determines if file is persistent or temp)
|
||||
if provided.PersistentFile != "" {
|
||||
merged.PersistentFile = provided.PersistentFile
|
||||
}
|
||||
|
||||
return &merged
|
||||
}
|
||||
@@ -90,13 +193,6 @@ func mergeConfig(provided *TempDBConfig) *TempDBConfig {
|
||||
// NewTempDB creates a new temporary SQLite database.
|
||||
// The database is created in the cache directory's sql/ subdirectory.
|
||||
// The database will be cleaned up on Close().
|
||||
//
|
||||
// The database is optimized for bulk write-then-read operations with:
|
||||
// - WAL journal mode for better concurrency
|
||||
// - Configurable cache size (default: ~8MB for one-time DBs)
|
||||
// - Memory-mapped I/O for faster access
|
||||
// - OFF synchronous mode for maximum write performance
|
||||
// - Configurable temp_store (default: FILE, can be set to MEMORY via config)
|
||||
func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
@@ -113,17 +209,25 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
|
||||
return nil, fmt.Errorf("failed to create sql directory: %w", err)
|
||||
}
|
||||
|
||||
// Create temporary file in the sql subdirectory
|
||||
tmpFile, err := os.CreateTemp(dbDir, fmt.Sprintf("%s.db", id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
// Determine database file path
|
||||
var tmpPath string
|
||||
if cfg.PersistentFile != "" {
|
||||
// Use fixed filename for persistent databases
|
||||
tmpPath = filepath.Join(dbDir, cfg.PersistentFile)
|
||||
// File might already exist (persistent), that's fine
|
||||
} else {
|
||||
// Create temporary file with random suffix
|
||||
tmpFile, err := os.CreateTemp(dbDir, fmt.Sprintf("%s.db", id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
tmpPath = tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
|
||||
// Open SQLite database with basic connection string
|
||||
// We'll set PRAGMAs after connection for better control and logging
|
||||
db, err := sql.Open("sqlite", tmpPath)
|
||||
// Driver is selected at compile time: "sqlite3" (CGO) or "sqlite" (pure Go)
|
||||
db, err := sql.Open(SqliteDriver, tmpPath)
|
||||
if err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
|
||||
@@ -136,28 +240,92 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
|
||||
return nil, fmt.Errorf("failed to ping SQLite database: %w", err)
|
||||
}
|
||||
|
||||
// Apply optimizations via PRAGMA statements
|
||||
// Execute them individually for compatibility and better error reporting
|
||||
pragmaStart := time.Now()
|
||||
// Limit connection pool to 1 for SQLite - it's a file-based database and multiple connections
|
||||
// can cause locking issues. With busy_timeout, SQLite will queue operations automatically.
|
||||
if cfg.LockingMode == "EXCLUSIVE" {
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
}
|
||||
|
||||
pragmas := []struct {
|
||||
basePragmas := []struct {
|
||||
sql string
|
||||
err string
|
||||
}{
|
||||
{"PRAGMA journal_mode = WAL;", "failed to set WAL mode"},
|
||||
{fmt.Sprintf("PRAGMA journal_mode = %s;", cfg.JournalMode), "failed to set journal_mode"},
|
||||
{fmt.Sprintf("PRAGMA cache_size = %d;", cfg.CacheSizeKB), "failed to set cache_size"},
|
||||
{fmt.Sprintf("PRAGMA synchronous = %s;", cfg.Synchronous), "failed to set synchronous"},
|
||||
{fmt.Sprintf("PRAGMA temp_store = %s;", cfg.TempStore), "failed to set temp_store"},
|
||||
{fmt.Sprintf("PRAGMA locking_mode = %s;", cfg.LockingMode), "failed to set locking_mode"},
|
||||
{fmt.Sprintf("PRAGMA auto_vacuum = %s;", cfg.AutoVacuum), "failed to set auto_vacuum"},
|
||||
}
|
||||
|
||||
if cfg.MmapSize > 0 {
|
||||
pragmas = append(pragmas, struct {
|
||||
// Memory management PRAGMAs - set early to constrain memory usage
|
||||
if cfg.SoftHeapLimitBytes > 0 {
|
||||
basePragmas = append([]struct {
|
||||
sql string
|
||||
err string
|
||||
}{fmt.Sprintf("PRAGMA mmap_size = %d;", cfg.MmapSize), "failed to set mmap_size"})
|
||||
}{{fmt.Sprintf("PRAGMA soft_heap_limit = %d;", cfg.SoftHeapLimitBytes), "failed to set soft_heap_limit"}}, basePragmas...)
|
||||
}
|
||||
|
||||
for _, pragma := range pragmas {
|
||||
if cfg.HardHeapLimitBytes > 0 {
|
||||
basePragmas = append([]struct {
|
||||
sql string
|
||||
err string
|
||||
}{{fmt.Sprintf("PRAGMA hard_heap_limit = %d;", cfg.HardHeapLimitBytes), "failed to set hard_heap_limit"}}, basePragmas...)
|
||||
}
|
||||
|
||||
// Cache spill threshold - triggers early spilling to reduce memory usage
|
||||
if cfg.CacheSpillThreshold > 0 {
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{fmt.Sprintf("PRAGMA cache_spill = %d;", cfg.CacheSpillThreshold), "failed to set cache_spill"})
|
||||
} else {
|
||||
// Enable cache_spill by default (value of 1 enables it)
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{"PRAGMA cache_spill = 1;", "failed to set cache_spill"})
|
||||
}
|
||||
|
||||
// Page size must be set before any tables are created
|
||||
if cfg.PageSize > 0 {
|
||||
basePragmas = append([]struct {
|
||||
sql string
|
||||
err string
|
||||
}{{fmt.Sprintf("PRAGMA page_size = %d;", cfg.PageSize), "failed to set page_size"}}, basePragmas...)
|
||||
}
|
||||
|
||||
// Always explicitly set mmap_size to ensure it's disabled (0) or enabled as configured
|
||||
// Setting it to 0 explicitly disables memory-mapped I/O, which helps reduce OS page cache usage
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{fmt.Sprintf("PRAGMA mmap_size = %d;", cfg.MmapSize), "failed to set mmap_size"})
|
||||
|
||||
// WAL autocheckpoint - controls checkpoint frequency in WAL mode
|
||||
if cfg.WalAutocheckpoint > 0 {
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{fmt.Sprintf("PRAGMA wal_autocheckpoint = %d;", cfg.WalAutocheckpoint), "failed to set wal_autocheckpoint"})
|
||||
}
|
||||
|
||||
// Journal size limit - limits WAL file growth
|
||||
if cfg.JournalSizeLimit > 0 {
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{fmt.Sprintf("PRAGMA journal_size_limit = %d;", cfg.JournalSizeLimit), "failed to set journal_size_limit"})
|
||||
} else if cfg.JournalSizeLimit == -1 {
|
||||
// -1 means no limit
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{"PRAGMA journal_size_limit = -1;", "failed to set journal_size_limit"})
|
||||
}
|
||||
|
||||
for _, pragma := range basePragmas {
|
||||
if _, err := db.Exec(pragma.sql); err != nil {
|
||||
db.Close()
|
||||
os.Remove(tmpPath)
|
||||
@@ -165,12 +333,9 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
|
||||
}
|
||||
}
|
||||
|
||||
pragmaDuration := time.Since(pragmaStart)
|
||||
|
||||
// Log configuration if enabled
|
||||
if cfg.EnableLogging {
|
||||
logger.Debugf("[TempDB:%s] Created with cache_size=%d KB, mmap_size=%d bytes, synchronous=%s, temp_store=%s (setup took %v)",
|
||||
id, cfg.CacheSizeKB, cfg.MmapSize, cfg.Synchronous, cfg.TempStore, pragmaDuration)
|
||||
// Verify PRAGMA settings were applied correctly
|
||||
if err := verifyPragmaSettings(db, cfg); err != nil {
|
||||
logger.Warningf("Failed to verify PRAGMA settings: %v", err)
|
||||
}
|
||||
|
||||
return &TempDB{
|
||||
@@ -178,6 +343,7 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
|
||||
path: tmpPath,
|
||||
startTime: startTime,
|
||||
config: cfg,
|
||||
BatchSize: cfg.BatchSize, // Export batch size for external packages
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -189,8 +355,11 @@ func (t *TempDB) DB() *sql.DB {
|
||||
|
||||
// BeginTransaction starts a transaction for bulk operations.
|
||||
// The caller must call Commit() or Rollback() on the returned transaction.
|
||||
// The mutex is NOT held during the transaction - caller is responsible for coordination.
|
||||
// IMPORTANT: The mutex is acquired here and MUST be released by calling
|
||||
// t.mu.Unlock() after commit/rollback to ensure exclusive transaction access.
|
||||
func (t *TempDB) BeginTransaction() (*sql.Tx, error) {
|
||||
t.mu.Lock()
|
||||
// NOTE: Mutex is NOT unlocked here! Caller must unlock after commit/rollback
|
||||
return t.db.Begin()
|
||||
}
|
||||
|
||||
@@ -218,32 +387,105 @@ func (t *TempDB) QueryRow(query string, args ...interface{}) *sql.Row {
|
||||
return t.db.QueryRow(query, args...)
|
||||
}
|
||||
|
||||
// Close closes the database connection and removes the temporary file.
|
||||
// This should always be called when done with the database, typically in a defer statement.
|
||||
// If logging is enabled, it will log the total lifetime and file size for performance analysis.
|
||||
// Close closes the database connection and removes the temporary file if it is not persistent.
|
||||
func (t *TempDB) Close() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.config != nil && t.config.EnableLogging {
|
||||
totalDuration := time.Since(t.startTime)
|
||||
fileInfo, err := os.Stat(t.path)
|
||||
var fileSize int64
|
||||
if err == nil {
|
||||
fileSize = fileInfo.Size()
|
||||
}
|
||||
logger.Debugf("[TempDB] Closed after %v, final size: %d bytes (%.2f MB)",
|
||||
totalDuration, fileSize, float64(fileSize)/(1024*1024))
|
||||
}
|
||||
|
||||
if t.db != nil {
|
||||
if err := t.db.Close(); err != nil {
|
||||
os.Remove(t.path)
|
||||
// Only remove file if it's not persistent
|
||||
if t.config != nil && t.config.PersistentFile == "" {
|
||||
os.Remove(t.path)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return os.Remove(t.path)
|
||||
// Only remove file if it's not persistent
|
||||
if t.config != nil && t.config.PersistentFile == "" {
|
||||
return os.Remove(t.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyPragmaSettings reads back PRAGMA values to verify they were applied correctly
|
||||
func verifyPragmaSettings(db *sql.DB, cfg *TempDBConfig) error {
|
||||
var cacheSize, mmapSize, softHeapLimit, hardHeapLimit, cacheSpill int64
|
||||
var journalMode, synchronous, tempStore, lockingMode, autoVacuum string
|
||||
|
||||
// Read back PRAGMA values
|
||||
if err := db.QueryRow("PRAGMA cache_size").Scan(&cacheSize); err != nil {
|
||||
return fmt.Errorf("failed to read cache_size: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA mmap_size").Scan(&mmapSize); err != nil {
|
||||
return fmt.Errorf("failed to read mmap_size: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA soft_heap_limit").Scan(&softHeapLimit); err != nil {
|
||||
return fmt.Errorf("failed to read soft_heap_limit: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA hard_heap_limit").Scan(&hardHeapLimit); err != nil {
|
||||
return fmt.Errorf("failed to read hard_heap_limit: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA cache_spill").Scan(&cacheSpill); err != nil {
|
||||
return fmt.Errorf("failed to read cache_spill: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
||||
return fmt.Errorf("failed to read journal_mode: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil {
|
||||
return fmt.Errorf("failed to read synchronous: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA temp_store").Scan(&tempStore); err != nil {
|
||||
return fmt.Errorf("failed to read temp_store: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA locking_mode").Scan(&lockingMode); err != nil {
|
||||
return fmt.Errorf("failed to read locking_mode: %w", err)
|
||||
}
|
||||
if err := db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
|
||||
return fmt.Errorf("failed to read auto_vacuum: %w", err)
|
||||
}
|
||||
|
||||
// Convert cache_size from pages to KB (negative = pages, positive = KB)
|
||||
cacheSizeKB := cacheSize
|
||||
if cacheSize < 0 {
|
||||
cacheSizeKB = -cacheSize * 4 // pages * 4KB per page
|
||||
}
|
||||
|
||||
// Log all settings
|
||||
logger.Debugf("[SQLITE_CONFIG] Database PRAGMA settings verified:")
|
||||
logger.Debugf(" cache_size: %d KB (configured: %d KB)", cacheSizeKB, cfg.CacheSizeKB)
|
||||
logger.Debugf(" mmap_size: %d bytes (configured: %d bytes)", mmapSize, cfg.MmapSize)
|
||||
logger.Debugf(" soft_heap_limit: %d bytes (configured: %d bytes)", softHeapLimit, cfg.SoftHeapLimitBytes)
|
||||
logger.Debugf(" hard_heap_limit: %d bytes (configured: %d bytes)", hardHeapLimit, cfg.HardHeapLimitBytes)
|
||||
logger.Debugf(" cache_spill: %d pages (configured: %d pages)", cacheSpill, cfg.CacheSpillThreshold)
|
||||
logger.Debugf(" journal_mode: %s (configured: %s)", journalMode, cfg.JournalMode)
|
||||
logger.Debugf(" synchronous: %s (configured: %s)", synchronous, cfg.Synchronous)
|
||||
logger.Debugf(" temp_store: %s (configured: %s)", tempStore, cfg.TempStore)
|
||||
logger.Debugf(" locking_mode: %s (configured: %s)", lockingMode, cfg.LockingMode)
|
||||
logger.Debugf(" auto_vacuum: %s (configured: %s)", autoVacuum, cfg.AutoVacuum)
|
||||
|
||||
// Verify critical settings match
|
||||
if cfg.CacheSizeKB != 0 {
|
||||
expectedCache := int64(cfg.CacheSizeKB)
|
||||
if expectedCache < 0 {
|
||||
expectedCache = -expectedCache * 4 // Convert pages to KB
|
||||
}
|
||||
if cacheSizeKB != expectedCache {
|
||||
logger.Warningf("[SQLITE_CONFIG] cache_size mismatch: got %d KB, expected %d KB", cacheSizeKB, expectedCache)
|
||||
}
|
||||
}
|
||||
if cfg.MmapSize > 0 && mmapSize != cfg.MmapSize {
|
||||
logger.Warningf("[SQLITE_CONFIG] mmap_size mismatch: got %d bytes, expected %d bytes", mmapSize, cfg.MmapSize)
|
||||
}
|
||||
if cfg.SoftHeapLimitBytes > 0 && softHeapLimit != cfg.SoftHeapLimitBytes {
|
||||
logger.Warningf("[SQLITE_CONFIG] soft_heap_limit mismatch: got %d bytes, expected %d bytes", softHeapLimit, cfg.SoftHeapLimitBytes)
|
||||
}
|
||||
if cfg.HardHeapLimitBytes > 0 && hardHeapLimit != cfg.HardHeapLimitBytes {
|
||||
logger.Warningf("[SQLITE_CONFIG] hard_heap_limit mismatch: got %d bytes, expected %d bytes", hardHeapLimit, cfg.HardHeapLimitBytes)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path returns the path to the temporary database file.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
@@ -18,6 +19,7 @@ type BoltStore struct {
|
||||
Auth *auth.Storage
|
||||
Settings *settings.Storage
|
||||
Access *access.Storage
|
||||
Indexing *indexing.Storage
|
||||
}
|
||||
|
||||
// NewStorage creates a storage.Storage based on Bolt DB.
|
||||
@@ -33,5 +35,6 @@ func NewStorage(db *storm.DB) (*BoltStore, error) {
|
||||
Auth: authStore,
|
||||
Settings: settings.NewStorage(settingsBackend{db: db}),
|
||||
Access: access.NewStorage(db, userStore),
|
||||
Indexing: indexing.NewStorage(indexingBackend{db: db}),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
storm "github.com/asdine/storm/v3"
|
||||
"github.com/asdine/storm/v3/q"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/indexing"
|
||||
)
|
||||
|
||||
type indexingBackend struct {
|
||||
db *storm.DB
|
||||
}
|
||||
|
||||
func (s indexingBackend) All() ([]*indexing.IndexInfo, error) {
|
||||
var v []*indexing.IndexInfo
|
||||
err := s.db.All(&v)
|
||||
if err == storm.ErrNotFound {
|
||||
return v, errors.ErrNotExist
|
||||
}
|
||||
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (s indexingBackend) GetByPath(path string) (*indexing.IndexInfo, error) {
|
||||
var v indexing.IndexInfo
|
||||
err := s.db.One("Path", path, &v)
|
||||
if err == storm.ErrNotFound {
|
||||
return nil, errors.ErrNotExist
|
||||
}
|
||||
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (s indexingBackend) GetBySource(source string) ([]*indexing.IndexInfo, error) {
|
||||
var v []*indexing.IndexInfo
|
||||
err := s.db.Select(q.Eq("Source", source)).Find(&v)
|
||||
if err == storm.ErrNotFound {
|
||||
return nil, errors.ErrNotExist
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (s indexingBackend) Save(info *indexing.IndexInfo) error {
|
||||
return s.db.Save(info)
|
||||
}
|
||||
|
||||
func (s indexingBackend) Delete(path string) error {
|
||||
err := s.db.DeleteStruct(&indexing.IndexInfo{Path: path})
|
||||
if err == storm.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
|
||||
// converting scopes to map of paths intead of names (names can change)
|
||||
if slices.Contains(fields, "Scopes") {
|
||||
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
|
||||
adjustedScopes, err := user.GetBackendScopes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
}
|
||||
// converting scopes to map of paths intead of names (names can change)
|
||||
if slices.Contains(fields, "SidebarLinks") {
|
||||
adjustedLinks, err := settings.ConvertToBackendSidebarLinks(user.SidebarLinks)
|
||||
adjustedLinks, err := user.GetBackendSidebarLinks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
|
||||
}
|
||||
|
||||
// converting scopes to map of paths intead of names (names can change)
|
||||
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
|
||||
adjustedScopes, err := user.GetBackendScopes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,9 +5,35 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
func init() {
|
||||
users.BcryptCost = 4 // bcrypt.MinCost for faster tests
|
||||
|
||||
// Initialize test source configuration
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
"/tmp/test": {
|
||||
Path: "/tmp/test",
|
||||
Name: "test",
|
||||
Config: settings.SourceConfig{
|
||||
DefaultUserScope: "/",
|
||||
},
|
||||
},
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
"test": settings.Config.Server.SourceMap["/tmp/test"],
|
||||
}
|
||||
settings.Config.Server.Sources = []*settings.Source{
|
||||
settings.Config.Server.SourceMap["/tmp/test"],
|
||||
}
|
||||
// Don't set default scopes for tests - let tests explicitly set scopes when needed
|
||||
settings.Config.UserDefaults.DefaultScopes = []users.SourceScope{}
|
||||
// Initialize user package resolvers
|
||||
settings.InitializeUserResolvers()
|
||||
}
|
||||
|
||||
func createTestUsersBackend(t *testing.T) users.StorageBackend {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetSourceNames returns all source names the user has access to (assumes backend-style scopes)
|
||||
func (u *User) GetSourceNames() []string {
|
||||
if sourceConfig == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
sources := []string{}
|
||||
allSources := sourceConfig.GetAllSources()
|
||||
|
||||
// Preserves order of sources
|
||||
for _, source := range allSources {
|
||||
_, err := u.GetScopeForSourcePath(source.Path)
|
||||
if err == nil {
|
||||
sources = append(sources, source.Name)
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
// GetBackendScopes converts the user's scopes from frontend-style to backend-style
|
||||
func (u *User) GetBackendScopes() ([]SourceScope, error) {
|
||||
// Only convert scopes if they are not empty
|
||||
// Empty scopes during update should remain empty (not filled with defaults)
|
||||
if len(u.Scopes) == 0 {
|
||||
return []SourceScope{}, nil
|
||||
}
|
||||
if sourceConfig == nil {
|
||||
return nil, fmt.Errorf("source config not initialized")
|
||||
}
|
||||
|
||||
newScopes := []SourceScope{}
|
||||
for _, scope := range u.Scopes {
|
||||
// First check if its already a path name and keep it
|
||||
source, ok := sourceConfig.GetSourceByPath(scope.Name)
|
||||
if ok {
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.DefaultUserScope
|
||||
}
|
||||
scope.Scope = normalizeScope(scope.Scope)
|
||||
newScopes = append(newScopes, SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if its the name of a source and convert it to a path
|
||||
source, ok = sourceConfig.GetSourceByName(scope.Name)
|
||||
if !ok {
|
||||
// source might no longer be configured
|
||||
continue
|
||||
}
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.DefaultUserScope
|
||||
}
|
||||
scope.Scope = normalizeScope(scope.Scope)
|
||||
newScopes = append(newScopes, SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
return newScopes, nil
|
||||
}
|
||||
|
||||
// GetFrontendScopes converts the user's scopes from backend-style to frontend-style
|
||||
// Backend scopes use source paths, frontend scopes use source names
|
||||
func (u *User) GetFrontendScopes() []SourceScope {
|
||||
if sourceConfig == nil {
|
||||
return []SourceScope{}
|
||||
}
|
||||
|
||||
newScopes := []SourceScope{}
|
||||
for _, scope := range u.Scopes {
|
||||
if source, ok := sourceConfig.GetSourceByPath(scope.Name); ok {
|
||||
// Replace scope.Name with source.Name while keeping the same Scope value
|
||||
newScopes = append(newScopes, SourceScope{
|
||||
Name: source.Name,
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
return newScopes
|
||||
}
|
||||
|
||||
// GetBackendSidebarLinks converts the user's sidebar links from frontend-style to backend-style
|
||||
// Validates that sources exist and converts source names to paths
|
||||
func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
|
||||
if sourceConfig == nil {
|
||||
return nil, fmt.Errorf("source config not initialized")
|
||||
}
|
||||
|
||||
newLinks := []SidebarLink{}
|
||||
for _, link := range u.SidebarLinks {
|
||||
// For source links, validate that the source exists using SourceName
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
|
||||
}
|
||||
// Validate source exists
|
||||
sourceInfo, ok := sourceConfig.GetSourceByName(link.SourceName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
|
||||
}
|
||||
link.SourceName = sourceInfo.Path // if name changes keep link alive
|
||||
}
|
||||
// Store the link as-is with all fields preserved
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks, nil
|
||||
}
|
||||
|
||||
// GetFrontendSidebarLinks converts the user's sidebar links from backend-style to frontend-style
|
||||
// Converts source paths to source names for the frontend
|
||||
func (u *User) GetFrontendSidebarLinks() []SidebarLink {
|
||||
if sourceConfig == nil {
|
||||
return []SidebarLink{}
|
||||
}
|
||||
|
||||
newLinks := []SidebarLink{}
|
||||
for _, link := range u.SidebarLinks {
|
||||
// For source links, validate that the source still exists
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
continue
|
||||
}
|
||||
// Check if source exists
|
||||
sourceInfo, ok := sourceConfig.GetSourceByPath(link.SourceName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
link.SourceName = sourceInfo.Name
|
||||
}
|
||||
// For share links, just pass through (shares are validated separately)
|
||||
// For all other links, pass through as-is
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks
|
||||
}
|
||||
|
||||
// normalizeScope ensures scope starts with / and doesn't end with / (except for root)
|
||||
func normalizeScope(scope string) string {
|
||||
if !strings.HasPrefix(scope, "/") {
|
||||
scope = "/" + scope
|
||||
}
|
||||
if scope != "/" && strings.HasSuffix(scope, "/") {
|
||||
scope = strings.TrimSuffix(scope, "/")
|
||||
}
|
||||
return scope
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var BcryptCost = bcrypt.DefaultCost
|
||||
|
||||
// HashPwd hashes a password.
|
||||
func HashPwd(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
@@ -120,9 +121,10 @@ type NonAdminEditable struct {
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
MaxConcurrent int `json:"maxConcurrentUpload"`
|
||||
ChunkSize int `json:"uploadChunkSizeMb"`
|
||||
ClearAll bool `json:"clearAll"`
|
||||
MaxConcurrent int `json:"maxConcurrentUpload"`
|
||||
UploadChunkSize int `json:"uploadChunkSizeMb"`
|
||||
ClearAll bool `json:"clearAll"`
|
||||
DownloadChunkSize int `json:"downloadChunkSizeMb"`
|
||||
}
|
||||
|
||||
// SidebarLink represents a customizable link in the sidebar.
|
||||
@@ -140,3 +142,85 @@ func CleanUsername(s string) string {
|
||||
s = strings.Replace(s, "..", "", -1)
|
||||
return s
|
||||
}
|
||||
|
||||
// SourceNameResolver is a function type that resolves source names to source paths
|
||||
// This avoids circular dependencies by allowing the settings package to provide the resolver
|
||||
type SourceNameResolver func(sourceName string) (sourcePath string, err error)
|
||||
|
||||
// SourceInfo represents basic information about a source needed for user operations
|
||||
type SourceInfo struct {
|
||||
Path string
|
||||
Name string
|
||||
DefaultUserScope string
|
||||
}
|
||||
|
||||
// SourceConfigProvider provides access to source configuration
|
||||
type SourceConfigProvider struct {
|
||||
GetSourceByPath func(path string) (SourceInfo, bool)
|
||||
GetSourceByName func(name string) (SourceInfo, bool)
|
||||
GetAllSources func() []SourceInfo
|
||||
GetDefaultScopes func() []SourceScope
|
||||
}
|
||||
|
||||
// Global variables set by the settings package
|
||||
var (
|
||||
sourceNameResolver SourceNameResolver
|
||||
sourceConfig *SourceConfigProvider
|
||||
)
|
||||
|
||||
// SetSourceNameResolver sets the global source name resolver
|
||||
// This should be called once during initialization by the settings package
|
||||
func SetSourceNameResolver(resolver SourceNameResolver) {
|
||||
sourceNameResolver = resolver
|
||||
}
|
||||
|
||||
// SetSourceConfig sets the global source configuration provider
|
||||
// This should be called once during initialization by the settings package
|
||||
func SetSourceConfig(config *SourceConfigProvider) {
|
||||
sourceConfig = config
|
||||
}
|
||||
|
||||
// GetScopeForSourcePath returns the scope for a given source path (backend-style)
|
||||
// This method works with backend-style scopes where Name is the source path
|
||||
func (u *User) GetScopeForSourcePath(sourcePath string) (string, error) {
|
||||
for _, scope := range u.Scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return scope.Scope, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("scope not found for source %v", sourcePath)
|
||||
}
|
||||
|
||||
// GetScopeForSourceName returns the scope for a given source name (frontend-style)
|
||||
// Uses the global SourceNameResolver to convert source names to paths
|
||||
func (u *User) GetScopeForSourceName(sourceName string) (string, error) {
|
||||
if sourceNameResolver == nil {
|
||||
return "", fmt.Errorf("source name resolver not initialized")
|
||||
}
|
||||
|
||||
sourcePath, err := sourceNameResolver(sourceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return u.GetScopeForSourcePath(sourcePath)
|
||||
}
|
||||
|
||||
// HasSourceByPath checks if the user has access to a given source path
|
||||
func (u *User) HasSourceByPath(sourcePath string) bool {
|
||||
for _, scope := range u.Scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSourcePaths returns all source paths the user has access to
|
||||
func (u *User) GetSourcePaths() []string {
|
||||
paths := make([]string, 0, len(u.Scopes))
|
||||
for _, scope := range u.Scopes {
|
||||
paths = append(paths, scope.Name)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
+26
-22
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/coreos/go-systemd/v22 v22.6.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
|
||||
@@ -13,8 +14,10 @@ require (
|
||||
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 v0.2.4
|
||||
github.com/gtsteffaniak/go-logger v1.0.2
|
||||
github.com/gtsteffaniak/go-push v0.1.0
|
||||
github.com/kovidgoyal/imaging v1.7.2
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -34,6 +37,7 @@ require (
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
codeberg.org/chavacava/garif v0.2.0 // indirect
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
|
||||
dev.gaijin.team/go/golib v0.6.0 // indirect
|
||||
@@ -44,17 +48,17 @@ 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/BurntSushi/toml v1.5.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.2.1 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.4.0 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.63.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||
github.com/air-verse/air v1.64.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.0 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.1 // 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
|
||||
@@ -73,7 +77,7 @@ require (
|
||||
github.com/breml/errchkjson v0.4.1 // indirect
|
||||
github.com/butuzov/ireturn v0.4.0 // indirect
|
||||
github.com/butuzov/mirror v1.3.0 // indirect
|
||||
github.com/catenacyber/perfsprint v0.10.0 // indirect
|
||||
github.com/catenacyber/perfsprint v0.10.1 // indirect
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charithe/durationcheck v0.0.11 // indirect
|
||||
@@ -100,9 +104,9 @@ require (
|
||||
github.com/firefart/nonamedreturns v1.0.6 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fzipp/gocyclo v0.6.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.17 // indirect
|
||||
github.com/go-critic/go-critic v0.14.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.18 // indirect
|
||||
github.com/go-critic/go-critic v0.14.3 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
@@ -127,17 +131,17 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/godoc-lint/godoc-lint v0.10.1 // indirect
|
||||
github.com/godoc-lint/godoc-lint v0.11.1 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/gohugoio/hugo v0.149.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20251209161508-25c597310d4b // indirect
|
||||
github.com/golang/geo v0.0.0-20251215195340-f7ee789d9fc1 // 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.6.2 // indirect
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.8.0 // indirect
|
||||
github.com/golangci/golines v0.14.0 // indirect
|
||||
github.com/golangci/misspell v0.7.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
github.com/golangci/revgrep v0.8.0 // indirect
|
||||
@@ -150,7 +154,7 @@ require (
|
||||
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
|
||||
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hexops/gotextdiff v1.0.3 // indirect
|
||||
@@ -164,13 +168,13 @@ require (
|
||||
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
|
||||
github.com/kulti/thelper v0.7.1 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.15 // indirect
|
||||
github.com/lasiar/canonicalheader v1.1.2 // indirect
|
||||
github.com/ldez/exptostd v0.4.5 // indirect
|
||||
github.com/ldez/gomoddirectives v0.7.1 // indirect
|
||||
github.com/ldez/gomoddirectives v0.8.0 // indirect
|
||||
github.com/ldez/grignotin v0.10.1 // indirect
|
||||
github.com/ldez/structtags v0.6.1 // indirect
|
||||
github.com/ldez/tagliatelle v0.7.2 // indirect
|
||||
github.com/ldez/usetesting v0.5.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -188,7 +192,7 @@ require (
|
||||
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.12.0 // indirect
|
||||
github.com/mgechev/revive v1.13.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
|
||||
@@ -223,18 +227,18 @@ require (
|
||||
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.22.10 // indirect
|
||||
github.com/securego/gosec/v2 v2.22.11 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sivchari/containedctx v1.0.3 // 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.1 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.12.0 // indirect
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
|
||||
github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.4.1 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
@@ -242,7 +246,7 @@ require (
|
||||
github.com/tetafro/godot v1.5.4 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
|
||||
github.com/timonwong/loggercheck v0.11.0 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
|
||||
github.com/ultraware/funlen v0.2.0 // indirect
|
||||
github.com/ultraware/whitespace v0.2.0 // indirect
|
||||
|
||||
+71
-70
@@ -36,6 +36,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY=
|
||||
codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ=
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI=
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y=
|
||||
@@ -62,6 +64,8 @@ github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZd
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
@@ -73,20 +77,33 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/MirrexOne/unqueryvet v1.2.1 h1:M+zdXMq84g+E1YOLa7g7ExN3dWfZQrdDSTCM7gC+m/A=
|
||||
github.com/MirrexOne/unqueryvet v1.2.1/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
|
||||
github.com/MirrexOne/unqueryvet v1.3.0 h1:5slWSomgqpYU4zFuZ3NNOfOUxVPlXFDBPAVasZOGlAY=
|
||||
github.com/MirrexOne/unqueryvet v1.3.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
|
||||
github.com/MirrexOne/unqueryvet v1.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ=
|
||||
github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
|
||||
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.63.1 h1:N6kD5niKKVx0wF2mW0mgK6LNfJqP5/lCAqm3WWl9vlw=
|
||||
github.com/air-verse/air v1.63.1/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
github.com/air-verse/air v1.63.4 h1:Z+R4328Bja5QKFMTP0CNeT8aVWdb3D5kbbFvnXnuRhE=
|
||||
github.com/air-verse/air v1.63.4/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
github.com/air-verse/air v1.63.7 h1:Wi85jUmyhGWcNpm/OUUvPiGNp65X+mUVKjr1N9HfZ3Q=
|
||||
github.com/air-verse/air v1.63.7/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
github.com/air-verse/air v1.64.0 h1:lKEvIQzaOM5vAVKOdQxq1WrXLfLmzz+e+hPrhmgF6yo=
|
||||
github.com/air-verse/air v1.64.0/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
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.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
|
||||
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
@@ -96,6 +113,8 @@ github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQ
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
|
||||
github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw=
|
||||
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
|
||||
github.com/alexkohler/prealloc v1.0.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE=
|
||||
github.com/alexkohler/prealloc v1.0.1/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=
|
||||
@@ -167,6 +186,8 @@ github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
|
||||
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
|
||||
github.com/catenacyber/perfsprint v0.10.0 h1:AZj1mYyxbxLRqmnYOeguZXEQwWOgQGm2wzLI5d7Hl/0=
|
||||
github.com/catenacyber/perfsprint v0.10.0/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
|
||||
github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
|
||||
github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
@@ -195,10 +216,10 @@ 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.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
|
||||
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
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.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
|
||||
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -263,8 +284,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
|
||||
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
|
||||
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
@@ -273,8 +294,12 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/ghostiam/protogetter v0.3.17 h1:sjGPErP9o7i2Ym+z3LsQzBdLCNaqbYy2iJQPxGXg04Q=
|
||||
github.com/ghostiam/protogetter v0.3.17/go.mod h1:AivIX1eKA/TcUmzZdzbl+Tb8tjIe8FcyG6JFyemQAH4=
|
||||
github.com/ghostiam/protogetter v0.3.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo=
|
||||
github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
|
||||
github.com/go-critic/go-critic v0.14.2 h1:PMvP5f+LdR8p6B29npvChUXbD1vrNlKDf60NJtgMBOo=
|
||||
github.com/go-critic/go-critic v0.14.2/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
|
||||
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
|
||||
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
@@ -294,48 +319,27 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
|
||||
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
|
||||
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
|
||||
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
@@ -348,8 +352,6 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
|
||||
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
@@ -384,12 +386,14 @@ github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4
|
||||
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godoc-lint/godoc-lint v0.10.1 h1:ZPUVzlDtJfA+P688JfPJPkI/SuzcBr/753yGIk5bOPA=
|
||||
github.com/godoc-lint/godoc-lint v0.10.1/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
|
||||
github.com/godoc-lint/godoc-lint v0.10.2 h1:dksNgK+zebnVlj4Fx83CRnCmPO0qRat/9xfFsir1nfg=
|
||||
github.com/godoc-lint/godoc-lint v0.10.2/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
|
||||
github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA=
|
||||
github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
@@ -414,10 +418,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-20251117194806-05dcfdd28b33 h1:1iCpF+lKihFpyN/Ujqvy9cWVlB516bGZItvb3l9HOSo=
|
||||
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20251209161508-25c597310d4b h1:6y9D6yfaR5FyqoNoV2S+XJyhzeMUlkdIeUX1Ssj0FJQ=
|
||||
github.com/golang/geo v0.0.0-20251209161508-25c597310d4b/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20251215195340-f7ee789d9fc1 h1:D/2kgdp+Hl79R59gNSnTQPpjWKd/IVUYhuvkqWQG8N0=
|
||||
github.com/golang/geo v0.0.0-20251215195340-f7ee789d9fc1/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=
|
||||
@@ -459,8 +461,14 @@ github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0a
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
|
||||
github.com/golangci/golangci-lint/v2 v2.6.2 h1:jkMSVv36JmyTENcEertckvimvjPcD5qxNM7W7qhECvI=
|
||||
github.com/golangci/golangci-lint/v2 v2.6.2/go.mod h1:fSIMDiBt9kzdpnvvV7GO6iWzyv5uaeZ+iPor+2uRczE=
|
||||
github.com/golangci/golangci-lint/v2 v2.7.2 h1:AhBC+YeEueec4AGlIbvPym5C70Thx0JykIqXbdIXWx0=
|
||||
github.com/golangci/golangci-lint/v2 v2.7.2/go.mod h1:pDijleoBu7e8sejMqyZ3L5n6geqe+cVvOAz2QImqqVc=
|
||||
github.com/golangci/golangci-lint/v2 v2.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA=
|
||||
github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI=
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8=
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ=
|
||||
github.com/golangci/golines v0.14.0 h1:xt9d3RKBjhasA3qpoXs99J2xN2t6eBlpLHt0TrgyyXc=
|
||||
github.com/golangci/golines v0.14.0/go.mod h1:gf555vPG2Ia7mmy2mzmhVQbVjuK8Orw0maR1G4vVAAQ=
|
||||
github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
|
||||
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
|
||||
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
|
||||
@@ -521,10 +529,10 @@ 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 v0.2.3 h1:8nSC5xSMnMYQI61tsBS1tARpnkv/rOh5lzEXtAG72Nw=
|
||||
github.com/gtsteffaniak/go-logger v0.2.3/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/gtsteffaniak/go-logger v0.2.4 h1:CGioQehoo84xufxtS57gqEAYcXdLc5hzA71CPvGQ/3s=
|
||||
github.com/gtsteffaniak/go-logger v0.2.4/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/gtsteffaniak/go-push v0.1.0 h1:bMkin7Ce2xjTllPMk5q3IAxD2ZcMDGZICuQeeKDJDXo=
|
||||
github.com/gtsteffaniak/go-push v0.1.0/go.mod h1:xZplgyS4bJdYmLbfEBbkETdrN/vQn6M/tim72fX2s1c=
|
||||
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=
|
||||
@@ -534,6 +542,8 @@ github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
|
||||
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -581,12 +591,8 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
|
||||
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.7.2 h1:mmT6k6Az3mC6dbqdZ6Q9KQCdZFWTAQ+q97NyGZgJ/2c=
|
||||
github.com/kovidgoyal/imaging v1.7.2/go.mod h1:GdkCORjfZMMGFY0Pb7TDmRhj7PDhxF/QShKukSCj0VU=
|
||||
github.com/kovidgoyal/imaging v1.8.17 h1:IDc7lbN2Qrn8s50y7Zt355HhOc+jUpvsScYAaGCW8vs=
|
||||
github.com/kovidgoyal/imaging v1.8.17/go.mod h1:uD4XKN42lLV9du0TsPkwi53yw23vz/qDmfpiDWCSUCE=
|
||||
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=
|
||||
@@ -607,8 +613,12 @@ github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
|
||||
github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
|
||||
github.com/ldez/gomoddirectives v0.7.1 h1:FaULkvUIG36hj6chpwa+FdCNGZBsD7/fO+p7CCsM6pE=
|
||||
github.com/ldez/gomoddirectives v0.7.1/go.mod h1:auDNtakWJR1rC+YX7ar+HmveqXATBAyEK1KYpsIRW/8=
|
||||
github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk=
|
||||
github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q=
|
||||
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
|
||||
github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
|
||||
github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk=
|
||||
github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY=
|
||||
github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk=
|
||||
github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI=
|
||||
github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
|
||||
@@ -647,10 +657,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/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.12.0 h1:Q+/kkbbwerrVYPv9d9efaPGmAO/NsxwW/nE6ahpQaCU=
|
||||
github.com/mgechev/revive v1.12.0/go.mod h1:VXsY2LsTigk8XU9BpZauVLjVrhICMOV3k1lpB3CXrp8=
|
||||
github.com/mgechev/revive v1.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM=
|
||||
github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0=
|
||||
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=
|
||||
@@ -674,8 +688,6 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
|
||||
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
|
||||
@@ -698,6 +710,7 @@ github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTH
|
||||
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE=
|
||||
github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
|
||||
@@ -788,6 +801,10 @@ github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iM
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
|
||||
github.com/securego/gosec/v2 v2.22.10 h1:ntbBqdWXnu46DUOXn+R2SvPo3PiJCDugTCgTW2g4tQg=
|
||||
github.com/securego/gosec/v2 v2.22.10/go.mod h1:9UNjK3tLpv/w2b0+7r82byV43wCJDNtEDQMeS+H/g2w=
|
||||
github.com/securego/gosec/v2 v2.22.11-0.20251204091113-daccba6b93d7 h1:rZg6IGn0ySYZwCX8LHwZoYm03JhG/cVAJJ3O+u3Vclo=
|
||||
github.com/securego/gosec/v2 v2.22.11-0.20251204091113-daccba6b93d7/go.mod h1:9sr22NZO5Kfh7unW/xZxkGYTmj2484/fCiE54gw7UTY=
|
||||
github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg=
|
||||
github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw=
|
||||
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=
|
||||
@@ -810,6 +827,8 @@ github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -822,6 +841,8 @@ github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YE
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
@@ -860,6 +881,8 @@ github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwT
|
||||
github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw=
|
||||
github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI=
|
||||
@@ -936,8 +959,6 @@ 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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -950,8 +971,6 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
@@ -960,8 +979,6 @@ golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqv
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/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.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -989,8 +1006,6 @@ 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.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1035,8 +1050,6 @@ 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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -1045,8 +1058,6 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1064,8 +1075,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1121,8 +1130,6 @@ 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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -1141,8 +1148,6 @@ 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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1200,8 +1205,6 @@ 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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
@@ -1321,20 +1324,18 @@ 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.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
|
||||
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/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.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
|
||||
+34
-20
@@ -57,9 +57,19 @@ func extractToken(r *http.Request) (string, error) {
|
||||
hasToken = true
|
||||
// Split the header to get "Bearer {token}"
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
token := parts[1]
|
||||
return token, nil
|
||||
if len(parts) > 1 {
|
||||
// some clients don't respect RFC regarding cases
|
||||
switch strings.ToLower(parts[0]) {
|
||||
case "bearer":
|
||||
return parts[1], nil
|
||||
case "basic":
|
||||
// compatibility for basic auth
|
||||
// user ignored, password is token
|
||||
_, token, ok := r.BasicAuth()
|
||||
if ok {
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,23 +278,7 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int,
|
||||
// This allows backend to identify expired sessions and provide better user feedback
|
||||
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
|
||||
|
||||
// Set the authentication token as an HTTP cookie
|
||||
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: signed.Key,
|
||||
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: expiresTime,
|
||||
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
|
||||
// Secure: true, // Enable this in production with HTTPS
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
setSessionCookie(w, r, signed.Key, expiresTime)
|
||||
|
||||
// Still return token in body for backward compatibility and state management
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
@@ -416,3 +410,23 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||
}
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
// setSessionCookie - sets the authentication token as an HTTP cookie
|
||||
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: token,
|
||||
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
|
||||
Expires: expiresTime,
|
||||
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
|
||||
// Secure: true, // Enable this in production with HTTPS
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
+587
-288
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,512 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/events"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
)
|
||||
|
||||
// fileWatchResponse represents the response from file watch
|
||||
type fileWatchResponse struct {
|
||||
Contents string `json:"contents,omitempty"` // Text content for text files
|
||||
IsText bool `json:"isText"` // Whether the file is a text file
|
||||
Metadata *fileWatchMetadata `json:"metadata,omitempty"` // File metadata for non-text files
|
||||
}
|
||||
|
||||
// fileWatchMetadata contains file information for non-text files
|
||||
type fileWatchMetadata struct {
|
||||
Name string `json:"name"` // File name
|
||||
Size int64 `json:"size"` // File size in bytes
|
||||
Type string `json:"type"` // MIME type
|
||||
Modified time.Time `json:"modified"` // Modification time
|
||||
Path string `json:"path"` // File path
|
||||
}
|
||||
|
||||
// readLastNLines reads the last N lines from a file efficiently
|
||||
func readLastNLines(filePath string, n int) (string, error) {
|
||||
if n <= 0 {
|
||||
return "", fmt.Errorf("number of lines must be positive")
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file size
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fileSize := stat.Size()
|
||||
if fileSize == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// For small files, just read everything
|
||||
// For larger files, read from the end
|
||||
const maxReadSize = 1024 * 1024 // 1MB
|
||||
var lines []string
|
||||
|
||||
if fileSize <= maxReadSize {
|
||||
// Read entire file for small files
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return "", err
|
||||
}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
// For large files, read from the end
|
||||
// Read the last chunk (up to 1MB) and count newlines
|
||||
readSize := maxReadSize
|
||||
if fileSize < int64(readSize) {
|
||||
readSize = int(fileSize)
|
||||
}
|
||||
|
||||
if _, err := file.Seek(-int64(readSize), io.SeekEnd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Return only the last N lines
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
|
||||
// Truncate lines that exceed 250 characters
|
||||
const maxLineLength = 250
|
||||
for i, line := range lines {
|
||||
if len(line) > maxLineLength {
|
||||
lines[i] = line[:maxLineLength] + "..."
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
// fileWatchHandler handles file watching requests
|
||||
// @Summary Watch a file
|
||||
// @Description Returns the last N lines of a file
|
||||
// @Tags Tools
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "Path to the file"
|
||||
// @Param source query string true "Source name"
|
||||
// @Param lines query int false "Number of lines to read (default: 10, max: 50)"
|
||||
// @Param latencyCheck query bool false "Return minimal response for latency checking"
|
||||
// @Success 200 {object} fileWatchResponse
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 403 {object} map[string]string "Permission denied"
|
||||
// @Failure 404 {object} map[string]string "File not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/tools/watch [get]
|
||||
func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Check for latency check request - return immediately with minimal response
|
||||
if r.URL.Query().Get("latencyCheck") != "" {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
linesStr := r.URL.Query().Get("lines")
|
||||
|
||||
// Decode URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
|
||||
if path == "" || source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and source are required")
|
||||
}
|
||||
|
||||
// Parse lines parameter
|
||||
lines := 10 // default
|
||||
if linesStr != "" {
|
||||
var parsedLines int
|
||||
parsedLines, err = strconv.Atoi(linesStr)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid lines parameter: %v", err)
|
||||
}
|
||||
if parsedLines < 1 || parsedLines > 50 {
|
||||
return http.StatusBadRequest, fmt.Errorf("lines must be between 1 and 50")
|
||||
}
|
||||
lines = parsedLines
|
||||
}
|
||||
|
||||
// Validate user has access to the source
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
// Check download permission (required to read file content)
|
||||
if !d.user.Permissions.Download {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to read file content")
|
||||
}
|
||||
|
||||
// Resolve the full path
|
||||
scopePath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
// Get the index for the source
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
// Check access control
|
||||
if store.Access != nil {
|
||||
if !store.Access.Permitted(idx.Path, scopePath, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
}
|
||||
|
||||
// Get real file path
|
||||
realPath, _, err := idx.GetRealPath(scopePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's a file, not a directory
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is a directory, not a file")
|
||||
}
|
||||
|
||||
// Check if file is a text file
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
|
||||
}
|
||||
|
||||
// Get MIME type from the index if available
|
||||
mimeType := "application/octet-stream"
|
||||
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
|
||||
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
|
||||
mimeType = reducedInfo.Type
|
||||
}
|
||||
|
||||
response := fileWatchResponse{
|
||||
IsText: isText,
|
||||
}
|
||||
|
||||
if isText {
|
||||
// Read the last N lines for text files
|
||||
content, err := readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error reading file: %v", err)
|
||||
}
|
||||
response.Contents = content
|
||||
// Include metadata for text files too (for file size display)
|
||||
response.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
} else {
|
||||
// For non-text files, return metadata
|
||||
response.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return http.StatusOK, json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// fileWatchSSEEvent represents the SSE event payload for file watch updates
|
||||
type fileWatchSSEEvent struct {
|
||||
Contents string `json:"contents,omitempty"` // Text content for text files
|
||||
IsText bool `json:"isText"` // Whether the file is a text file
|
||||
Metadata *fileWatchMetadata `json:"metadata,omitempty"` // File metadata for non-text files
|
||||
}
|
||||
|
||||
// fileWatchSSEHandler handles Server-Sent Events for file watching
|
||||
// @Summary Watch a file via SSE
|
||||
// @Description Establishes an SSE connection to receive periodic file updates
|
||||
// @Tags Tools
|
||||
// @Param path query string true "Path to the file"
|
||||
// @Param source query string true "Source name"
|
||||
// @Param lines query int false "Number of lines to read (default: 10, max: 50)"
|
||||
// @Param interval query int false "Update interval in seconds (1, 2, 5, 10, 15, or 30, requires realtime permission for SSE)"
|
||||
// @Success 200 "SSE stream"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 403 {object} map[string]string "Permission denied"
|
||||
// @Failure 404 {object} map[string]string "File not found"
|
||||
// @Router /api/tools/watch/sse [get]
|
||||
func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Check realtime permissions
|
||||
if !(d.user.Permissions.Realtime) {
|
||||
return http.StatusForbidden, fmt.Errorf("realtime permission required for SSE file watching")
|
||||
}
|
||||
|
||||
if !d.user.Permissions.Download {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to read file content")
|
||||
}
|
||||
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
linesStr := r.URL.Query().Get("lines")
|
||||
intervalStr := r.URL.Query().Get("interval")
|
||||
|
||||
// Decode URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
|
||||
if path == "" || source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and source are required")
|
||||
}
|
||||
|
||||
// Parse lines parameter
|
||||
lines := 10 // default
|
||||
if linesStr != "" {
|
||||
var parsedLines int
|
||||
parsedLines, err = strconv.Atoi(linesStr)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid lines parameter: %v", err)
|
||||
}
|
||||
if parsedLines < 1 || parsedLines > 50 {
|
||||
return http.StatusBadRequest, fmt.Errorf("lines must be between 1 and 50")
|
||||
}
|
||||
lines = parsedLines
|
||||
}
|
||||
|
||||
// Parse interval parameter (valid intervals: 1, 2, 5, 10, 15, 30 seconds)
|
||||
interval := time.Second * 1 // default
|
||||
if intervalStr != "" {
|
||||
var parsedInterval int
|
||||
parsedInterval, err = strconv.Atoi(intervalStr)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid interval parameter: %v", err)
|
||||
}
|
||||
validIntervals := map[int]bool{1: true, 2: true, 5: true, 10: true, 15: true, 30: true}
|
||||
if !validIntervals[parsedInterval] {
|
||||
return http.StatusBadRequest, fmt.Errorf("interval must be one of: 1, 2, 5, 10, 15, 30 seconds")
|
||||
}
|
||||
interval = time.Duration(parsedInterval) * time.Second
|
||||
}
|
||||
|
||||
// Validate user has access to the source
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
// Resolve the full path
|
||||
scopePath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
// Get the index for the source
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
// Check access control
|
||||
if store.Access != nil {
|
||||
if !store.Access.Permitted(idx.Path, scopePath, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to file")
|
||||
}
|
||||
}
|
||||
|
||||
// Get real file path
|
||||
realPath, _, err := idx.GetRealPath(scopePath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's a file, not a directory
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return http.StatusBadRequest, fmt.Errorf("path is a directory, not a file")
|
||||
}
|
||||
|
||||
// Set up SSE headers
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
return http.StatusInternalServerError, fmt.Errorf("streaming not supported")
|
||||
}
|
||||
|
||||
msgr := messenger{flusher: flusher, writer: w}
|
||||
clientGone := r.Context().Done()
|
||||
username := d.user.Username
|
||||
|
||||
// Initial ack
|
||||
statusMsg, _ := json.Marshal(map[string]interface{}{"status": "connected"})
|
||||
if err := msgr.sendEvent("fileWatch", string(statusMsg)); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error sending initial message: %v", err)
|
||||
}
|
||||
|
||||
// Register this client with the events system (like general SSE handler)
|
||||
sendChan := events.Register(username, []string{source})
|
||||
defer events.Unregister(username, sendChan)
|
||||
|
||||
// Get MIME type (we'll reuse this)
|
||||
mimeType := "application/octet-stream"
|
||||
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
|
||||
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
|
||||
mimeType = reducedInfo.Type
|
||||
}
|
||||
|
||||
// Start background goroutine to periodically send file updates via events system
|
||||
stopTicker := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Send initial update
|
||||
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopTicker:
|
||||
return
|
||||
case <-ticker.C:
|
||||
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Main loop: listen for events from events system (like general SSE handler)
|
||||
// Use server context if available, otherwise use request context
|
||||
serverCtx := r.Context()
|
||||
if d.ctx != nil {
|
||||
serverCtx = d.ctx
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-serverCtx.Done():
|
||||
close(stopTicker)
|
||||
_ = msgr.sendEvent("fileWatch", "\"server shutting down\"")
|
||||
return http.StatusOK, nil
|
||||
|
||||
case <-clientGone:
|
||||
close(stopTicker)
|
||||
return http.StatusOK, nil
|
||||
|
||||
case msg, ok := <-sendChan:
|
||||
if !ok {
|
||||
close(stopTicker)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
// Only process fileWatch events for this connection
|
||||
if msg.EventType == "fileWatch" {
|
||||
if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil {
|
||||
close(stopTicker)
|
||||
return http.StatusInternalServerError, fmt.Errorf("error sending event: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendFileWatchUpdate reads the file and sends an update via the events system
|
||||
func sendFileWatchUpdate(username, realPath, path string, lines int, mimeType string) {
|
||||
// Re-check file (in case it was deleted or changed)
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
// File no longer exists
|
||||
errorMsg, _ := json.Marshal(map[string]interface{}{"status": "error", "error": "file not found"})
|
||||
events.SendToUsers("fileWatch", string(errorMsg), []string{username})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file is a text file
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
// Error checking file, skip this update
|
||||
return
|
||||
}
|
||||
|
||||
// Build the SSE event
|
||||
sseEvent := fileWatchSSEEvent{
|
||||
IsText: isText,
|
||||
}
|
||||
|
||||
if isText {
|
||||
// Read the last N lines for text files
|
||||
var content string
|
||||
content, err = readLastNLines(realPath, lines)
|
||||
if err != nil {
|
||||
// Error reading, skip this update
|
||||
return
|
||||
}
|
||||
sseEvent.Contents = content
|
||||
sseEvent.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
} else {
|
||||
// For non-text files, send metadata
|
||||
sseEvent.Metadata = &fileWatchMetadata{
|
||||
Name: info.Name(),
|
||||
Size: info.Size(),
|
||||
Type: mimeType,
|
||||
Modified: info.ModTime(),
|
||||
Path: path,
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize and send via events system
|
||||
eventJSON, err := json.Marshal(sseEvent)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Send via events system to this user (like OnlyOffice does)
|
||||
events.SendToUsers("fileWatch", string(eventJSON), []string{username})
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/events"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -56,9 +55,13 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
|
||||
}
|
||||
|
||||
// Register this client with the events system
|
||||
sendChan := events.Register(username, settings.GetSources(d.user))
|
||||
sendChan := events.Register(username, d.user.GetSourceNames())
|
||||
defer events.Unregister(username, sendChan)
|
||||
|
||||
// Heartbeat ticker - send every 30 seconds to keep connection alive
|
||||
heartbeatTicker := time.NewTicker(30 * time.Second)
|
||||
defer heartbeatTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-d.ctx.Done():
|
||||
@@ -68,6 +71,12 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
|
||||
case <-clientGone:
|
||||
return http.StatusOK, nil
|
||||
|
||||
case <-heartbeatTicker.C:
|
||||
// Send minimal heartbeat message (ignored by frontend)
|
||||
if err := msgr.sendEvent("heartbeat", "\"hb\""); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error sending heartbeat: %v, user: %s", err, username)
|
||||
}
|
||||
|
||||
case msg := <-events.BroadcastChan:
|
||||
if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error sending broadcast: %v, user: %s", err, username)
|
||||
|
||||
@@ -7,12 +7,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
_ "net/http/pprof"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/activation"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/events"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -44,6 +47,15 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
store = storage
|
||||
config = &settings.Config
|
||||
var err error
|
||||
// Start pprof server in a separate goroutine
|
||||
if settings.Env.IsDevMode {
|
||||
go func() {
|
||||
if err = http.ListenAndServe("localhost:6060", nil); err != nil {
|
||||
logger.Fatalf("pprof server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Determine filesystem mode and set asset paths
|
||||
if settings.Env.EmbeddedFs {
|
||||
// Embedded mode: Serve files from the embedded assets
|
||||
@@ -87,6 +99,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("POST /users", withSelfOrAdmin(usersPostHandler))
|
||||
api.HandleFunc("PUT /users", withUser(userPutHandler))
|
||||
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
|
||||
api.HandleFunc("GET /health", healthHandler)
|
||||
|
||||
// Auth routes
|
||||
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
|
||||
@@ -108,10 +121,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("POST /resources", withUser(resourcePostHandler))
|
||||
api.HandleFunc("PUT /resources", withUser(resourcePutHandler))
|
||||
api.HandleFunc("PATCH /resources", withUser(resourcePatchHandler))
|
||||
api.HandleFunc("POST /resources/bulk/delete", withUser(resourceBulkDeleteHandler))
|
||||
api.HandleFunc("GET /raw", withUser(rawHandler))
|
||||
api.HandleFunc("GET /preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
|
||||
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
|
||||
if version.Version == "testing" || version.Version == "untracked" {
|
||||
if settings.Env.IsDevMode {
|
||||
api.HandleFunc("GET /inspectIndex", inspectIndex)
|
||||
api.HandleFunc("GET /mockData", mockData)
|
||||
}
|
||||
@@ -146,8 +160,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
publicAPI.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
|
||||
publicAPI.HandleFunc("PUT /resources", withHashFile(publicPutHandler))
|
||||
publicAPI.HandleFunc("DELETE /resources", withHashFile(publicDeleteHandler))
|
||||
publicAPI.HandleFunc("POST /resources/bulk/delete", withHashFile(publicBulkDeleteHandler))
|
||||
publicAPI.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
|
||||
publicAPI.HandleFunc("GET /shareinfo", withOrWithoutUser(shareInfoHandler))
|
||||
publicAPI.HandleFunc("GET /share/image", withHashFile(getShareImage))
|
||||
|
||||
// Settings routes
|
||||
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
|
||||
@@ -167,14 +183,23 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
|
||||
api.HandleFunc("GET /search", withUser(searchHandler))
|
||||
api.HandleFunc("GET /duplicates", withUser(duplicatesHandler))
|
||||
api.HandleFunc("GET /tools/watch", withUser(fileWatchHandler))
|
||||
api.HandleFunc("GET /tools/watch/sse", withUser(fileWatchSSEHandler))
|
||||
// Mount the public API sub-router
|
||||
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicAPI))
|
||||
|
||||
// Mount the route groups
|
||||
apiPath := config.Server.BaseURL + "api"
|
||||
publicPath := config.Server.BaseURL + "public"
|
||||
webDavPath := config.Server.BaseURL + "dav"
|
||||
router.Handle(apiPath+"/", http.StripPrefix(apiPath, api))
|
||||
router.Handle(publicPath+"/", http.StripPrefix(publicPath, publicRoutes))
|
||||
// WebDav resources (catch-all)
|
||||
// similar to NextCloud and others
|
||||
// (reddec) do not trim /dav prefix here - webdav library for some reason does not like it.
|
||||
router.Handle(webDavPath+"/{scope}/{path...}", wrapHandlerOpts(withUserHelper(createWebDAVHandler(webDavPath)), wrapperOpts{
|
||||
requestBasicAuth: true,
|
||||
}))
|
||||
|
||||
// Frontend share route redirect (DEPRECATED - maintain for backwards compatibility)
|
||||
// Playwright tests need updating to remove this redirect.
|
||||
@@ -236,11 +261,27 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
|
||||
logger.Infof("Running at : %s", fullURL)
|
||||
|
||||
// Create a TLS listener and serve
|
||||
listener, err := tls.Listen("tcp", srv.Addr, tlsConfig)
|
||||
if err != nil {
|
||||
logger.Fatalf("Could not start TLS server: %v", err)
|
||||
// Attempt to get listener from socket activation with TLS configuration
|
||||
var listener net.Listener
|
||||
|
||||
socketActivationListeners, err := activation.TLSListeners(tlsConfig)
|
||||
if err == nil && len(socketActivationListeners) > 0 {
|
||||
listener = socketActivationListeners[0]
|
||||
logger.Debug("Socket activation detected. Listening address is being controlled by systemd.")
|
||||
} else if err != nil {
|
||||
// if Socket Activation fails we can just fall back to create our own sockets as normal.
|
||||
// so is only interesting to those who are debugging the app.
|
||||
logger.Debugf("Socket activation failed: %v", err)
|
||||
}
|
||||
|
||||
// Create our own TLS listener if socket activation is not available.
|
||||
if listener == nil {
|
||||
listener, err = tls.Listen("tcp", srv.Addr, tlsConfig)
|
||||
if err != nil {
|
||||
logger.Fatalf("Could not start TLS server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatalf("Server error: %v", err)
|
||||
}
|
||||
@@ -255,8 +296,35 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
|
||||
logger.Infof("Running at : %s", fullURL)
|
||||
|
||||
var listener net.Listener
|
||||
|
||||
// Attempt to get the listener from socket activation
|
||||
socketActivationListeners, err := activation.Listeners()
|
||||
if err == nil && len(socketActivationListeners) > 0 {
|
||||
listener = socketActivationListeners[0]
|
||||
logger.Debug("Socket activation detected. Listening address is being controlled by systemd.")
|
||||
} else if err != nil {
|
||||
// if Socket Activation fails we can just fall back to create our own sockets as normal.
|
||||
// so is only interesting to those who are debugging the app.
|
||||
logger.Debugf("Socket activation failed: %v", err)
|
||||
}
|
||||
|
||||
// Create our own listener if socket activation is not available.
|
||||
if listener == nil {
|
||||
// Replicate the behaviour of ListenAndServe
|
||||
addr := srv.Addr
|
||||
if addr == "" {
|
||||
addr = ":http"
|
||||
}
|
||||
|
||||
listener, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
logger.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -281,6 +349,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
logger.Errorf("Failed to flush access storage: %v", err)
|
||||
}
|
||||
}
|
||||
if store.Indexing != nil {
|
||||
if err := store.Indexing.Flush(); err != nil {
|
||||
logger.Errorf("Failed to flush indexing storage: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown with a timeout - 30 seconds, in case downloads are happening
|
||||
|
||||
@@ -3,7 +3,6 @@ package http
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -20,7 +19,7 @@ import (
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/jobs/{action}/{target} [get]
|
||||
func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
sources := settings.GetSources(d.user)
|
||||
sources := d.user.GetSourceNames()
|
||||
reducedIndexes := map[string]indexing.ReducedIndex{}
|
||||
for _, source := range sources {
|
||||
reducedIndex, err := indexing.GetIndexInfo(source, false)
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
)
|
||||
@@ -47,7 +46,7 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
userscope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
@@ -60,7 +59,7 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
metadata, exists := idx.GetMetadataInfo(userscope, true)
|
||||
metadata, exists := idx.GetMetadataInfo(userscope, true, false)
|
||||
if !exists {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
+49
-23
@@ -32,6 +32,8 @@ type requestContext struct {
|
||||
shareValid bool
|
||||
ctx context.Context
|
||||
MaxBandwidth int
|
||||
Data interface{}
|
||||
IndexPath string
|
||||
}
|
||||
|
||||
type HttpResponse struct {
|
||||
@@ -94,8 +96,10 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
if source.Config.Private {
|
||||
return http.StatusForbidden, fmt.Errorf("the target source is private")
|
||||
}
|
||||
|
||||
// Get file information with options
|
||||
getContent := r.URL.Query().Get("content") == "true"
|
||||
getMetadata := r.URL.Query().Get("metadata") == "true"
|
||||
reachedDownloadsLimit := link.Downloads >= link.DownloadsLimit && link.DownloadsLimit > 0
|
||||
if link.DisableFileViewer || reachedDownloadsLimit {
|
||||
getContent = false
|
||||
@@ -104,16 +108,27 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
|
||||
}
|
||||
// get user scope path from share
|
||||
userScope, err := shareCreatedByUser.GetScopeForSourceName(source.Name)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
// so trim user scope from link.Path
|
||||
userScopedPath := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
|
||||
if !strings.HasSuffix(userScopedPath, "/") {
|
||||
userScopedPath = userScopedPath + "/"
|
||||
}
|
||||
data.IndexPath = userScopedPath
|
||||
file, err := FileInfoFasterFunc(utils.FileOptions{
|
||||
Path: utils.JoinPathAsUnix(link.Path, path),
|
||||
Source: link.Source,
|
||||
Username: shareCreatedByUser.Username,
|
||||
Path: userScopedPath,
|
||||
Source: source.Name,
|
||||
Expand: true,
|
||||
Content: getContent,
|
||||
Metadata: getMetadata,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: link.ShowHidden,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access)
|
||||
}, store.Access, shareCreatedByUser)
|
||||
if err != nil {
|
||||
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
|
||||
return errToStatus(err), fmt.Errorf("error fetching share from server")
|
||||
@@ -392,6 +407,10 @@ func withUserHelper(fn handleFunc) handleFunc {
|
||||
logger.Errorf("Failed to get user with ID %v: %v", tk.BelongsTo, err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// Set cookie. Some clients like gvfs relies on it for concurrent uploads
|
||||
if expire := tk.ExpiresAt; expire != nil {
|
||||
setSessionCookie(w, r, tokenString, expire.Time)
|
||||
}
|
||||
setUserInResponseWriter(w, data.user)
|
||||
if data.user.Username == "" {
|
||||
return http.StatusForbidden, errors.ErrUnauthorized
|
||||
@@ -445,6 +464,14 @@ func withSelfOrAdminHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
|
||||
func wrapHandler(fn handleFunc) http.HandlerFunc {
|
||||
return wrapHandlerOpts(fn, wrapperOpts{})
|
||||
}
|
||||
|
||||
type wrapperOpts struct {
|
||||
requestBasicAuth bool
|
||||
}
|
||||
|
||||
func wrapHandlerOpts(fn handleFunc, opts wrapperOpts) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := &requestContext{}
|
||||
|
||||
@@ -458,6 +485,10 @@ func wrapHandler(fn handleFunc) http.HandlerFunc {
|
||||
Message: err.Error(),
|
||||
}
|
||||
|
||||
if status == http.StatusUnauthorized && opts.requestBasicAuth {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
}
|
||||
|
||||
// Set the content type to JSON and status code
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
@@ -617,8 +648,6 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
// DEFER RECOVERY FUNCTION
|
||||
defer func() {
|
||||
if rcv := recover(); rcv != nil {
|
||||
// Log detailed information about the panic
|
||||
// Extract as much context as possible for logging
|
||||
method := r.Method
|
||||
url := r.URL.String()
|
||||
remoteAddr := r.RemoteAddr
|
||||
@@ -628,12 +657,6 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
if ww, ok := w.(*ResponseWriterWrapper); ok && ww.User != "" {
|
||||
username = ww.User
|
||||
}
|
||||
// Or try to get it from request context if your other middleware populates it
|
||||
// This depends on your context setup; example:
|
||||
// if dataCtx, ok := r.Context().Value("requestData").(*requestContext); ok && dataCtx.user != nil {
|
||||
// username = dataCtx.user.Username
|
||||
// }
|
||||
|
||||
// Get Go-level stack trace
|
||||
buf := make([]byte, 16384) // Increased buffer size for potentially long CGo traces
|
||||
n := runtime.Stack(buf, false) // false for current goroutine only
|
||||
@@ -653,16 +676,11 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
_, _ = renderJSON(w, r, &HttpResponse{
|
||||
Status: 500,
|
||||
Message: "A critical internal error occurred. Please try again later.",
|
||||
})
|
||||
}, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// IMPORTANT: After a SIGSEGV from C code, the process might be unstable.
|
||||
// Even if Go recovers, continuing to run the process is risky.
|
||||
// Consider a strategy to gracefully shut down or signal an external supervisor
|
||||
// to restart the process after logging. For now, this will allow other requests
|
||||
// to proceed if the process doesn't die, but be wary.
|
||||
}
|
||||
}() // End of deferred recovery function
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
wrappedWriter := &ResponseWriterWrapper{ResponseWriter: w, StatusCode: http.StatusOK}
|
||||
@@ -695,18 +713,26 @@ func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func renderJSON(w http.ResponseWriter, r *http.Request, data interface{}) (int, error) {
|
||||
func renderJSON(w http.ResponseWriter, r *http.Request, data interface{}, statusCode ...int) (int, error) {
|
||||
// Default to 200 if status code not provided
|
||||
code := http.StatusOK
|
||||
if len(statusCode) > 0 && statusCode[0] != 0 {
|
||||
code = statusCode[0]
|
||||
}
|
||||
|
||||
marsh, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// Calculate size in KB
|
||||
payloadSizeKB := len(marsh) / 1024
|
||||
// Set headers before writing
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
// Check if the client accepts gzip encoding and hasn't explicitly disabled it
|
||||
if acceptsGzip(r) && payloadSizeKB > 10 {
|
||||
// Enable gzip compression
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.WriteHeader(code)
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
@@ -715,13 +741,13 @@ func renderJSON(w http.ResponseWriter, r *http.Request, data interface{}) (int,
|
||||
}
|
||||
} else {
|
||||
// Normal response without compression
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
if _, err := w.Write(marsh); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func acceptsGzip(r *http.Request) bool {
|
||||
|
||||
@@ -34,6 +34,14 @@ func setupTestEnv(t *testing.T) {
|
||||
Name: "srv",
|
||||
},
|
||||
}
|
||||
config.Server.NameToSource = map[string]*settings.Source{
|
||||
"srv": &settings.Source{
|
||||
Path: "/srv",
|
||||
Name: "srv",
|
||||
},
|
||||
}
|
||||
// Initialize user resolvers so users package can resolve source names
|
||||
settings.InitializeUserResolvers()
|
||||
mockFileInfoFaster(t) // Mock FileInfoFasterFunc for this test
|
||||
}
|
||||
|
||||
@@ -44,7 +52,7 @@ func mockFileInfoFaster(t *testing.T) {
|
||||
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
|
||||
|
||||
// Mock the function to skip execution
|
||||
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return &iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
Path: opts.Path,
|
||||
@@ -145,6 +153,9 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Permissions: users.Permissions{Admin: false},
|
||||
Scopes: []users.SourceScope{
|
||||
{Name: "srv", Scope: "/"}, // Root scope on srv source
|
||||
},
|
||||
}
|
||||
if err := store.Users.Save(dummyUser, true, true); err != nil {
|
||||
t.Fatal("failed to save dummy user:", err)
|
||||
@@ -167,7 +178,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
Source: "/srv",
|
||||
},
|
||||
},
|
||||
expectedStatusCode: 0, // zero means 200 on helpers
|
||||
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
|
||||
},
|
||||
{
|
||||
name: "Private share, valid password when token exists",
|
||||
@@ -183,7 +194,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
extraHeaders: map[string]string{
|
||||
"X-SHARE-PASSWORD": "password",
|
||||
},
|
||||
expectedStatusCode: 0, // zero means 200 on helpers
|
||||
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
|
||||
},
|
||||
{
|
||||
name: "Private share, no auth provided",
|
||||
@@ -207,7 +218,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
|
||||
},
|
||||
},
|
||||
token: "123",
|
||||
expectedStatusCode: 0, // zero means 200 on helpers
|
||||
expectedStatusCode: http.StatusOK, // zero means 200 on helpers
|
||||
},
|
||||
{
|
||||
name: "Private share, invalid password",
|
||||
|
||||
+13
-13
@@ -100,21 +100,14 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
path := providedPath
|
||||
if hash == "" {
|
||||
// Build file info based on whether this is a share or regular request
|
||||
// Regular user request - need to resolve scope
|
||||
userScope, scopeErr := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if scopeErr != nil {
|
||||
logger.Errorf("OnlyOffice: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
|
||||
return http.StatusForbidden, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
logger.Debugf("OnlyOffice user request: resolved path=%s", path)
|
||||
// Regular user request
|
||||
logger.Debugf("OnlyOffice user request: request path=%s", path)
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Username: d.user.Username,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, path, err)
|
||||
return errToStatus(err), err
|
||||
@@ -365,7 +358,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
|
||||
if d.fileInfo.Hash == "" {
|
||||
// Regular user request - need to resolve scope
|
||||
userScope, scopeErr := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
userScope, scopeErr := d.user.GetScopeForSourceName(source)
|
||||
if scopeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
|
||||
return returnOnlyOfficeError(w, r, 403, "source not available")
|
||||
@@ -521,7 +514,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
_, err = files.FileInfoFaster(utils.FileOptions{
|
||||
Source: source,
|
||||
Path: path,
|
||||
}, nil)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice callback: original file no longer exists at path=%s: %v",
|
||||
path, err)
|
||||
@@ -535,7 +528,14 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
return returnOnlyOfficeError(w, r, 404, "original file no longer exists - it may have been renamed or moved")
|
||||
}
|
||||
|
||||
writeErr := files.WriteFile(source, path, doc.Body)
|
||||
// Get user scope to resolve full index path for write operation
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return returnOnlyOfficeError(w, r, 403, "user scope not found")
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
writeErr := files.WriteFile(source, fullIndexPath, doc.Body)
|
||||
if writeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: failed to write updated document to path=%s: %v",
|
||||
path, writeErr)
|
||||
|
||||
+20
-40
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -16,7 +15,6 @@ import (
|
||||
"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/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
@@ -62,30 +60,29 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
var err error
|
||||
// decode url encoded source name
|
||||
source, err = url.PathUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
if path == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid request path")
|
||||
}
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
if source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("source is required")
|
||||
}
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Username: d.user.Username,
|
||||
Path: utils.JoinPathAsUnix(userscope, path),
|
||||
Path: path,
|
||||
Source: source,
|
||||
AlbumArt: true, // Extract album art for audio previews
|
||||
}, store.Access)
|
||||
Expand: true,
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
logger.Errorf("error getting file info: %v", err)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
d.fileInfo = *fileInfo
|
||||
return previewHelperFunc(w, r, d)
|
||||
status, err := previewHelperFunc(w, r, d)
|
||||
if err != nil {
|
||||
logger.Errorf("error getting preview: %v", err)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
|
||||
@@ -93,8 +90,7 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(file.Path)
|
||||
fd, err := os.Open(realPath)
|
||||
fd, err := os.Open(file.RealPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -108,48 +104,37 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
|
||||
}
|
||||
|
||||
// getDirectoryPreview finds a valid preview file within a directory.
|
||||
// It iterates through files, checking if they should bubble up to folder previews,
|
||||
// and returns the first valid, non-corrupted file suitable for preview.
|
||||
func getDirectoryPreview(r *http.Request, d *requestContext, accessStore *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.ExtendedFileInfo, error) {
|
||||
var lastErr error
|
||||
|
||||
for _, item := range d.fileInfo.Files {
|
||||
// Only use files that should bubble up to folder previews (images, videos, audio)
|
||||
// Exclude text files, office documents, and PDFs
|
||||
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
continue
|
||||
}
|
||||
|
||||
source := d.fileInfo.Source
|
||||
path := utils.JoinPathAsUnix(d.fileInfo.Path, item.Name)
|
||||
|
||||
if d.share != nil {
|
||||
// Get the actual source name from the share's source mapping
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source not found for share")
|
||||
}
|
||||
source = sourceInfo.Name
|
||||
path = utils.JoinPathAsUnix(d.share.Path, path)
|
||||
path = d.IndexPath + item.Name
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(
|
||||
utils.FileOptions{
|
||||
Username: d.user.Username,
|
||||
Path: path,
|
||||
Source: source,
|
||||
AlbumArt: true, // Extract album art for audio previews
|
||||
}, accessStore)
|
||||
Metadata: true,
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue // Try next file if this one fails
|
||||
}
|
||||
|
||||
// Try to generate preview to verify the file is not corrupted
|
||||
tempCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
|
||||
cancel()
|
||||
|
||||
if previewErr != nil {
|
||||
// Skip context errors (timeout or cancellation) - they're not corruption issues
|
||||
if !errors.Is(previewErr, context.Canceled) && !errors.Is(previewErr, context.DeadlineExceeded) {
|
||||
@@ -164,11 +149,8 @@ func getDirectoryPreview(r *http.Request, d *requestContext, accessStore *access
|
||||
lastErr = previewErr
|
||||
continue
|
||||
}
|
||||
|
||||
// Success! Use this file for the directory preview
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
// If we exhausted all files and none worked, return the last error
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("no valid preview files found in directory: %w", lastErr)
|
||||
@@ -185,14 +167,12 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
if !d.fileInfo.HasPreview {
|
||||
return http.StatusBadRequest, fmt.Errorf("this item does not have a preview")
|
||||
}
|
||||
accessStore := store.Access
|
||||
if d.share != nil {
|
||||
accessStore = nil
|
||||
}
|
||||
|
||||
if d.fileInfo.Type == "directory" {
|
||||
// Get extended file info of first previewable item in directory
|
||||
fileInfo, err := getDirectoryPreview(r, d, accessStore)
|
||||
fileInfo, err := getDirectoryPreview(r, d)
|
||||
if err != nil {
|
||||
logger.Errorf("error getting directory preview: %v", err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
d.fileInfo = *fileInfo
|
||||
|
||||
+184
-103
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
// @Accept json
|
||||
// @Produce octet-stream
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
// @Param files query string true "Files to download in format: 'source::path||source::path'. Example: '/file1||/folder/file2'"
|
||||
// @Param files query string true "Comma-separated list of file paths. Example: 'file1.txt,folder/file2.txt'"
|
||||
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
|
||||
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
|
||||
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
|
||||
@@ -86,31 +86,16 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
actualSourceName := sourceInfo.Name
|
||||
|
||||
// Parse comma-separated file list
|
||||
fileList := []string{}
|
||||
for _, file := range strings.Split(f, "||") {
|
||||
// Check if file already contains source prefix (source::path format)
|
||||
if strings.Contains(file, "::") {
|
||||
splitFile := strings.SplitN(file, "::", 2)
|
||||
if len(splitFile) == 2 {
|
||||
source := splitFile[0]
|
||||
path := splitFile[1]
|
||||
// Join the share path with the requested path
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, path)
|
||||
fileList = append(fileList, source+"::"+filePath)
|
||||
} else {
|
||||
// Fallback: treat as plain path
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, file)
|
||||
fileList = append(fileList, actualSourceName+"::"+filePath)
|
||||
}
|
||||
} else {
|
||||
// Plain path without source prefix - use the actual source name from share
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, file)
|
||||
fileList = append(fileList, actualSourceName+"::"+filePath)
|
||||
}
|
||||
for _, file := range strings.Split(f, ",") {
|
||||
// Join the share path with the requested path
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, file)
|
||||
fileList = append(fileList, filePath)
|
||||
}
|
||||
|
||||
var status int
|
||||
status, err = rawFilesHandler(w, r, d, fileList)
|
||||
status, err = rawFilesHandler(w, r, d, actualSourceName, fileList)
|
||||
if err != nil {
|
||||
if err == errors.ErrDownloadNotAllowed {
|
||||
return http.StatusForbidden, errors.ErrDownloadNotAllowed
|
||||
@@ -129,6 +114,8 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
// @Param path query string false "Path within the share to retrieve information for. Defaults to share root."
|
||||
// @Param content query string false "Include file content if true"
|
||||
// @Param metadata query string false "Extract audio/video metadata if true"
|
||||
// @Success 200 {object} iteminfo.FileInfo "File or directory metadata"
|
||||
// @Failure 403 {object} map[string]string "Share unavailable or access denied"
|
||||
// @Failure 404 {object} map[string]string "Share not found or file not found"
|
||||
@@ -186,7 +173,7 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
logger.Errorf("public upload handler: error uploading with error %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("upload failure occured on backend")
|
||||
}
|
||||
return status, err
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// health godoc
|
||||
@@ -226,30 +213,14 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if config.Server.DisablePreviews || d.share.DisableThumbnails {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
|
||||
}
|
||||
|
||||
if d.share.ShareType == "upload" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled for upload shares")
|
||||
}
|
||||
|
||||
// Restore source name from share for preview generation
|
||||
// The middleware clears file.Source for security, but we need it for index lookups
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
fileInfo, err := FileInfoFasterFunc(utils.FileOptions{
|
||||
Path: utils.JoinPathAsUnix(d.share.Path, d.fileInfo.Path),
|
||||
Source: source,
|
||||
Metadata: true,
|
||||
AlbumArt: true,
|
||||
ShowHidden: d.share.ShowHidden,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
d.fileInfo = *fileInfo
|
||||
d.fileInfo.Source = d.share.Source
|
||||
d.fileInfo.Path = utils.JoinPathAsUnix(d.share.Path, r.URL.Query().Get("path"))
|
||||
status, err := previewHelperFunc(w, r, d)
|
||||
if err != nil {
|
||||
logger.Errorf("public preview handler: error getting preview with error %v", err)
|
||||
// Obfuscate errors for shares to prevent information leakage
|
||||
return http.StatusNotFound, fmt.Errorf("preview not available for this item")
|
||||
}
|
||||
@@ -293,7 +264,12 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
|
||||
err = files.WriteFile(source, resolvedPath, r.Body)
|
||||
return errToStatus(err), err
|
||||
// hide the error
|
||||
if err != nil {
|
||||
logger.Errorf("public put handler: error updating resource with error %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occurred while updating the resource")
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -316,13 +292,12 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Username: d.user.Username,
|
||||
FollowSymlinks: true,
|
||||
Path: indexPath,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
ShowHidden: d.share.ShowHidden,
|
||||
}, nil)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
@@ -330,84 +305,190 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath), fileInfo.Type == "directory")
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
logger.Errorf("public delete handler: error deleting resource with error %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
// publicBulkDeleteHandler deletes multiple resources from a public share in a single request.
|
||||
// @Summary Bulk delete resources from public share
|
||||
// @Description Deletes multiple resources specified in the request body. Returns a list of succeeded and failed deletions.
|
||||
// @Tags Public Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
// @Param items body []BulkDeleteItem true "Array of items to delete, each with source and path"
|
||||
// @Success 200 {object} BulkDeleteResponse "All resources deleted successfully"
|
||||
// @Success 207 {object} BulkDeleteResponse "Partial success - some resources deleted, some failed"
|
||||
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or empty items array"
|
||||
// @Failure 403 {object} map[string]string "Forbidden - delete not allowed for this share"
|
||||
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
|
||||
// @Router /public/api/resources/bulk/delete [post]
|
||||
func publicBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.share.AllowDelete {
|
||||
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
|
||||
}
|
||||
|
||||
// hide the error
|
||||
status, err := resourceBulkDeleteHandler(w, r, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occurred while processing the request")
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// publicPatchHandler performs a patch operation (e.g., move, copy, rename) on resources in a public share.
|
||||
// @Summary Move, copy, or rename resources in a public share
|
||||
// @Description Performs move, copy, or rename operations on multiple resources within a public share. All operations are performed atomically.
|
||||
// @Tags Public Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
// @Param request body MoveCopyRequest true "Move/copy request with items and action"
|
||||
// @Success 200 {object} MoveCopyResponse "All operations completed successfully"
|
||||
// @Success 207 {object} MoveCopyResponse "Partial success - some operations succeeded, some failed"
|
||||
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or parameters"
|
||||
// @Failure 403 {object} map[string]string "Forbidden - modify not allowed for this share"
|
||||
// @Failure 404 {object} map[string]string "Share or resource not found"
|
||||
// @Failure 500 {object} MoveCopyResponse "Internal server error"
|
||||
// @Router /public/api/resources [patch]
|
||||
func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.share.AllowModify {
|
||||
return http.StatusForbidden, fmt.Errorf("edit permission not allowed for this share")
|
||||
}
|
||||
// The middleware clears file.Source for security, but we need it for index lookups
|
||||
|
||||
// Get the source from the share
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
action := r.URL.Query().Get("action")
|
||||
encodedFrom := r.URL.Query().Get("from")
|
||||
// Decode the URL-encoded path
|
||||
src, err := url.QueryUnescape(encodedFrom)
|
||||
|
||||
// Replace user with the share creator's user for proper permission checking
|
||||
shareCreatedByUser, err := store.Users.Get(d.share.UserID)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
|
||||
}
|
||||
dst := r.URL.Query().Get("destination")
|
||||
dst, err = url.QueryUnescape(dst)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
d.user = shareCreatedByUser
|
||||
|
||||
// Parse the request body
|
||||
var req MoveCopyRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source for share not found")
|
||||
if req.Action == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("action is required (copy, move, or rename)")
|
||||
}
|
||||
|
||||
// get full paths for both locations
|
||||
srcFullPath := utils.JoinPathAsUnix(d.share.Path, src)
|
||||
if srcFullPath == "/" {
|
||||
return http.StatusForbidden, fmt.Errorf("an error occured accessing the share")
|
||||
// Transform the request: prepend share path and add source to each item
|
||||
// This normalizes the request to look like a regular user request
|
||||
// Note: Share paths are absolute, so we don't strip user scope here
|
||||
// resourcePatchHandler will skip adding scope for shares
|
||||
for i := range req.Items {
|
||||
req.Items[i].FromSource = source
|
||||
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].FromPath)
|
||||
req.Items[i].ToSource = source
|
||||
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].ToPath)
|
||||
}
|
||||
dstFullPath := utils.JoinPathAsUnix(d.share.Path, dst)
|
||||
if dstFullPath == "/" {
|
||||
return http.StatusForbidden, fmt.Errorf("an error occured accessing the share")
|
||||
d.Data = req
|
||||
|
||||
// Call the regular handler (will treat this like a normal user request now)
|
||||
status, err := resourcePatchHandler(w, r, d)
|
||||
|
||||
// For shares, we need to sanitize the response to hide internal details
|
||||
// The response has already been written by resourcePatchHandler, but we can still return error
|
||||
if err != nil {
|
||||
logger.Errorf("public patch handler: error processing patch with error %v", err)
|
||||
// Obfuscate errors for security
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occurred while processing the request")
|
||||
}
|
||||
|
||||
// get real source and isDir
|
||||
realSrc, isSrcDir, err := idx.GetRealPath(srcFullPath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
// get real destination and isDir parent
|
||||
parentRealDest, _, err := idx.GetRealPath(filepath.Dir(dstFullPath))
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
dstFullPath = parentRealDest + "/" + filepath.Base(dst)
|
||||
rename := r.URL.Query().Get("rename") == "true"
|
||||
if rename {
|
||||
dstFullPath = addVersionSuffix(dstFullPath)
|
||||
}
|
||||
// Validate move/rename operation to prevent circular references
|
||||
if action == "rename" || action == "move" {
|
||||
err = validateMoveOperation(realSrc, dstFullPath, isSrcDir)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid move or rename operation")
|
||||
}
|
||||
}
|
||||
err = patchAction(r.Context(), patchActionParams{
|
||||
action: action,
|
||||
srcIndex: source,
|
||||
dstIndex: source,
|
||||
src: realSrc,
|
||||
dst: dstFullPath,
|
||||
d: d,
|
||||
isSrcDir: isSrcDir,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debugf("Could not run patch action. src=%v dst=%v err=%v", realSrc, dstFullPath, err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occured while processing the request")
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
return status, err
|
||||
}
|
||||
|
||||
// getShareImage serves banner or favicon files for shares
|
||||
// @Summary Get share image (banner or favicon)
|
||||
// @Description Serves the banner or favicon file for a share
|
||||
// @Tags Public Shares
|
||||
// @Produce octet-stream
|
||||
// @Param hash query string true "Share hash"
|
||||
// @Param banner query bool false "Request banner file"
|
||||
// @Param favicon query bool false "Request favicon file"
|
||||
// @Success 200 {file} binary "Image file content"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 403 {object} map[string]string "Permission denied"
|
||||
// @Failure 404 {object} map[string]string "Asset not found"
|
||||
// @Router /public/api/share/image [get]
|
||||
func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Determine which asset is being requested
|
||||
isBanner := r.URL.Query().Get("banner") == "true"
|
||||
isFavicon := r.URL.Query().Get("favicon") == "true"
|
||||
|
||||
if !isBanner && !isFavicon {
|
||||
return http.StatusBadRequest, fmt.Errorf("either banner or favicon parameter must be true")
|
||||
}
|
||||
|
||||
shareCreatedByUser, err := store.Users.Get(d.share.UserID)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
|
||||
}
|
||||
|
||||
sourceName, assetPath, err := getShareImagePartsHelper(d.share, isBanner)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid asset configuration: %v", err)
|
||||
}
|
||||
|
||||
// Get file info
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: assetPath,
|
||||
Source: sourceName,
|
||||
Expand: false,
|
||||
Content: false,
|
||||
Metadata: false,
|
||||
ShowHidden: false,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, shareCreatedByUser)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("error accessing share asset: source=%v path=%v error=%v", sourceName, assetPath, err)
|
||||
return http.StatusNotFound, fmt.Errorf("asset file not found or not accessible")
|
||||
}
|
||||
|
||||
// Ensure it's an image file
|
||||
if !strings.HasPrefix(fileInfo.Type, "image/") {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid file type, must be image")
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
http.ServeFile(w, r, fileInfo.RealPath)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
|
||||
|
||||
// Get the asset query string from share
|
||||
var assetQueryString string
|
||||
if isBanner {
|
||||
assetQueryString = share.Banner
|
||||
} else {
|
||||
assetQueryString = share.Favicon
|
||||
}
|
||||
|
||||
if assetQueryString == "" {
|
||||
return "", "", fmt.Errorf("asset not configured for this share")
|
||||
}
|
||||
|
||||
// Parse the query string to extract source and path
|
||||
assetParams, err := url.ParseQuery(assetQueryString)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
|
||||
}
|
||||
|
||||
sourceName := assetParams.Get("source")
|
||||
assetPath := assetParams.Get("path")
|
||||
|
||||
return sourceName, assetPath, nil
|
||||
}
|
||||
+55
-69
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
@@ -94,10 +93,11 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
// @Description **Filename Encoding:**
|
||||
// @Description - The Content-Disposition header will always include both:
|
||||
// @Description 1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
|
||||
// @Description 2. `filename*=utf-8”...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
|
||||
// @Description 2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Param files query string true "a list of files in the following format 'source::filename' and separated by '||' with additional items in the list. (required)"
|
||||
// @Param source query string true "Source name for the files (required)"
|
||||
// @Param files query string true "Comma-separated list of file paths (required)"
|
||||
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
|
||||
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
|
||||
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
|
||||
@@ -107,29 +107,16 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/raw [get]
|
||||
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
files := r.URL.Query().Get("files")
|
||||
fileList := strings.Split(files, "||")
|
||||
return rawFilesHandler(w, r, d, fileList)
|
||||
source := r.URL.Query().Get("source")
|
||||
filesParam := r.URL.Query().Get("files")
|
||||
fileList := strings.Split(filesParam, ",")
|
||||
return rawFilesHandler(w, r, d, source, fileList)
|
||||
}
|
||||
|
||||
func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
|
||||
splitFile := strings.Split(path, "::")
|
||||
if len(splitFile) != 2 {
|
||||
return fmt.Errorf("invalid file in files requested: %v", splitFile)
|
||||
}
|
||||
source := splitFile[0]
|
||||
path = splitFile[1]
|
||||
|
||||
var err error
|
||||
if d.share == nil {
|
||||
var userScope string
|
||||
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
|
||||
}
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
}
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
func addFile(source string, path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
|
||||
// Path handling is now done inside FileInfoFaster
|
||||
// For shares, path comes from share context
|
||||
// For regular users, FileInfoFaster will apply user scope
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
@@ -144,12 +131,12 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
_, err = files.FileInfoFaster(utils.FileOptions{
|
||||
_, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
FollowSymlinks: true,
|
||||
}, nil)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -279,18 +266,16 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
|
||||
return nil
|
||||
}
|
||||
|
||||
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, fileList []string) (int, error) {
|
||||
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
|
||||
if !d.user.Permissions.Download && d.share == nil {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to download")
|
||||
}
|
||||
splitFile := strings.Split(fileList[0], "::")
|
||||
if len(splitFile) != 2 {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid file in files request: %v", fileList[0])
|
||||
|
||||
if len(fileList) == 0 {
|
||||
return http.StatusBadRequest, fmt.Errorf("no files specified")
|
||||
}
|
||||
|
||||
firstFileSource := splitFile[0]
|
||||
firstFilePath := splitFile[1]
|
||||
// decode url encoded source name
|
||||
firstFilePath := fileList[0]
|
||||
var err error
|
||||
var userscope string
|
||||
fileName := filepath.Base(firstFilePath)
|
||||
@@ -301,12 +286,12 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
var logContext *OnlyOfficeLogContext
|
||||
|
||||
if d.share == nil {
|
||||
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, firstFileSource)
|
||||
userscope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice file
|
||||
if isOnlyOffice {
|
||||
// Try to get document ID for error logging
|
||||
idx := indexing.GetIndex(firstFileSource)
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx != nil {
|
||||
tempPath := utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
if realPath, _, realErr := idx.GetRealPath(tempPath); realErr == nil {
|
||||
@@ -324,14 +309,14 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
}
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
idx := indexing.GetIndex(firstFileSource)
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice file
|
||||
if isOnlyOffice {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - source index not available: %s", firstFileSource))
|
||||
fmt.Sprintf("OnlyOffice download failed - source index not available: %s", source))
|
||||
}
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
realPath, isDir, err := idx.GetRealPath(firstFilePath)
|
||||
if err != nil {
|
||||
@@ -347,7 +332,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// Compute estimated download size
|
||||
estimatedSize, err := computeArchiveSize(fileList, d)
|
||||
estimatedSize, err := computeArchiveSize(source, fileList, d)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -461,10 +446,10 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
archiveData := filepath.Join(config.Server.CacheDir, utils.InsecureRandomIdentifier(10))
|
||||
if extension == ".zip" {
|
||||
archiveData = archiveData + ".zip"
|
||||
err = createZip(d, archiveData, fileList...)
|
||||
err = createZip(d, source, archiveData, fileList...)
|
||||
} else {
|
||||
archiveData = archiveData + ".tar.gz"
|
||||
err = createTarGz(d, archiveData, fileList...)
|
||||
err = createTarGz(d, source, archiveData, fileList...)
|
||||
}
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
@@ -514,43 +499,44 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
|
||||
func computeArchiveSize(source string, fileList []string, d *requestContext) (int64, error) {
|
||||
var estimatedSize int64
|
||||
for _, fname := range fileList {
|
||||
splitFile := strings.Split(fname, "::")
|
||||
if len(splitFile) != 2 {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid file in files request: %v", fileList[0])
|
||||
}
|
||||
source := splitFile[0]
|
||||
path := splitFile[1]
|
||||
var err error
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return 0, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
var userScope string
|
||||
if d.share == nil {
|
||||
userScope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
|
||||
}
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return 0, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
var userScope string
|
||||
var err error
|
||||
if d.share == nil {
|
||||
userScope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range fileList {
|
||||
var fullPath string
|
||||
if d.share == nil {
|
||||
fullPath = utils.JoinPathAsUnix(userScope, path)
|
||||
// Check access control for each file in the archive
|
||||
// Silently skip if access is denied (as if the file doesn't exist)
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, fullPath, d.user.Username) {
|
||||
continue // Skip this file and continue with the next one
|
||||
}
|
||||
} else {
|
||||
fullPath = path
|
||||
}
|
||||
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
realPath, isDir, err := idx.GetRealPath(path)
|
||||
realPath, isDir, err := idx.GetRealPath(fullPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
indexPath := idx.MakeIndexPath(realPath)
|
||||
indexPath := idx.MakeIndexPath(realPath, isDir)
|
||||
info, ok := idx.GetReducedMetadata(indexPath, isDir)
|
||||
if !ok {
|
||||
info, err = idx.GetFsInfo(indexPath, false)
|
||||
info, err = idx.GetFsInfo(indexPath, false, true)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
|
||||
}
|
||||
@@ -560,7 +546,7 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
|
||||
return estimatedSize, nil
|
||||
}
|
||||
|
||||
func createZip(d *requestContext, tmpDirPath string, filenames ...string) error {
|
||||
func createZip(d *requestContext, source string, tmpDirPath string, filenames ...string) error {
|
||||
file, err := os.Create(tmpDirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -570,7 +556,7 @@ func createZip(d *requestContext, tmpDirPath string, filenames ...string) error
|
||||
zipWriter := zip.NewWriter(file)
|
||||
|
||||
for _, fname := range filenames {
|
||||
err := addFile(fname, d, nil, zipWriter, false)
|
||||
err := addFile(source, fname, d, nil, zipWriter, false)
|
||||
if err != nil {
|
||||
// Access control failures return nil, so any error here is a real error
|
||||
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
|
||||
@@ -586,7 +572,7 @@ func createZip(d *requestContext, tmpDirPath string, filenames ...string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func createTarGz(d *requestContext, tmpDirPath string, filenames ...string) error {
|
||||
func createTarGz(d *requestContext, source string, tmpDirPath string, filenames ...string) error {
|
||||
file, err := os.Create(tmpDirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -597,7 +583,7 @@ func createTarGz(d *requestContext, tmpDirPath string, filenames ...string) erro
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
for _, fname := range filenames {
|
||||
err := addFile(fname, d, tarWriter, nil, false)
|
||||
err := addFile(source, fname, d, tarWriter, nil, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
|
||||
return err
|
||||
|
||||
+487
-167
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -63,6 +64,7 @@ func validateMoveOperation(src, dst string, isSrcDir bool) error {
|
||||
// @Param path query string true "Path to the resource"
|
||||
// @Param source query string true "Source name for the desired source, default is used if not provided"
|
||||
// @Param content query string false "Include file content if true"
|
||||
// @Param metadata query string false "Extract audio/video metadata if true"
|
||||
// @Param checksum query string false "Optional checksum validation"
|
||||
// @Success 200 {object} iteminfo.FileInfo "Resource metadata"
|
||||
// @Failure 404 {object} map[string]string "Resource not found"
|
||||
@@ -80,46 +82,29 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
userscope = strings.TrimRight(userscope, "/")
|
||||
scopePath := utils.JoinPathAsUnix(userscope, path)
|
||||
getContent := r.URL.Query().Get("content") == "true"
|
||||
getMetadata := r.URL.Query().Get("metadata") == "true"
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Username: d.user.Username,
|
||||
FollowSymlinks: true,
|
||||
Path: scopePath,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: true,
|
||||
Content: getContent,
|
||||
Metadata: true,
|
||||
Metadata: getMetadata,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
}, store.Access)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
if !d.user.Permissions.Download && fileInfo.Content != "" {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to get content, requires download permission")
|
||||
}
|
||||
if userscope != "/" {
|
||||
fileInfo.Path = strings.TrimPrefix(fileInfo.Path, userscope)
|
||||
}
|
||||
if fileInfo.Path == "" {
|
||||
fileInfo.Path = "/"
|
||||
}
|
||||
if fileInfo.Type == "directory" {
|
||||
return renderJSON(w, r, fileInfo)
|
||||
}
|
||||
if algo := r.URL.Query().Get("checksum"); algo != "" {
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(userscope, path)
|
||||
checksum, err := utils.GetChecksum(realPath, algo)
|
||||
checksum, err := utils.GetChecksum(fileInfo.RealPath, algo)
|
||||
if err == errors.ErrInvalidOption {
|
||||
return http.StatusBadRequest, nil
|
||||
} else if err != nil {
|
||||
@@ -166,20 +151,14 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
if path == "/" {
|
||||
return http.StatusForbidden, nil
|
||||
return http.StatusForbidden, fmt.Errorf("cannot delete your user's root directory")
|
||||
}
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
userscope = strings.TrimRight(userscope, "/")
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Username: d.user.Username,
|
||||
Path: utils.JoinPathAsUnix(userscope, path),
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
ShowHidden: d.user.ShowHidden,
|
||||
}, store.Access)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -187,7 +166,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath), fileInfo.Type == "directory")
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -195,6 +174,201 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
|
||||
}
|
||||
|
||||
// BulkDeleteItem represents a single item in a bulk delete request
|
||||
type BulkDeleteItem struct {
|
||||
Source string `json:"source"`
|
||||
Path string `json:"path"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// BulkDeleteResponse represents the response from a bulk delete operation
|
||||
type BulkDeleteResponse struct {
|
||||
Succeeded []BulkDeleteItem `json:"succeeded"`
|
||||
Failed []BulkDeleteItem `json:"failed"`
|
||||
}
|
||||
|
||||
// MoveCopyItem represents a single item in a move/copy request
|
||||
type MoveCopyItem struct {
|
||||
FromSource string `json:"fromSource,omitempty"`
|
||||
FromPath string `json:"fromPath,omitempty"`
|
||||
ToSource string `json:"toSource,omitempty"`
|
||||
ToPath string `json:"toPath,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// MoveCopyRequest represents a move/copy operation request
|
||||
type MoveCopyRequest struct {
|
||||
Items []MoveCopyItem `json:"items"`
|
||||
Action string `json:"action"` // "copy", "move", or "rename"
|
||||
Overwrite bool `json:"overwrite"` // Overwrite if destination exists
|
||||
Rename bool `json:"rename"` // Auto-rename if destination exists
|
||||
}
|
||||
|
||||
// MoveCopyResponse represents the response from a move/copy operation
|
||||
type MoveCopyResponse struct {
|
||||
Succeeded []MoveCopyItem `json:"succeeded"`
|
||||
Failed []MoveCopyItem `json:"failed"`
|
||||
}
|
||||
|
||||
// resourceBulkDeleteHandler deletes multiple resources in a single request.
|
||||
// @Summary Bulk delete resources
|
||||
// @Description Deletes multiple resources specified in the request body. Returns a list of succeeded and failed deletions.
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param items body []BulkDeleteItem true "Array of items to delete, each with source and path"
|
||||
// @Success 200 {object} BulkDeleteResponse "All resources deleted successfully"
|
||||
// @Success 207 {object} BulkDeleteResponse "Partial success - some resources deleted, some failed"
|
||||
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or empty items array"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
|
||||
// @Router /api/resources/bulk/delete [post]
|
||||
func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Check permissions - either user delete permission or share delete permission
|
||||
if d.share == nil {
|
||||
if !d.user.Permissions.Delete {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var items []BulkDeleteItem
|
||||
if err := json.NewDecoder(r.Body).Decode(&items); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return http.StatusBadRequest, fmt.Errorf("items array cannot be empty")
|
||||
}
|
||||
|
||||
response := BulkDeleteResponse{
|
||||
Succeeded: make([]BulkDeleteItem, 0),
|
||||
Failed: make([]BulkDeleteItem, 0),
|
||||
}
|
||||
|
||||
// Process each item one at a time
|
||||
for _, item := range items {
|
||||
// Validate item
|
||||
if item.Path == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Message: "path was empty",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Prevent deletion of root
|
||||
if item.Path == "/" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Message: "cannot delete root directory",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if d.share != nil {
|
||||
indexPath := utils.JoinPathAsUnix(d.share.Path, item.Path)
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: indexPath,
|
||||
Source: source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
|
||||
// Delete the file/directory
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Message: "error deleting file/directory, admin must check the logs",
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
} else {
|
||||
// Regular user context - validate source and check user scope
|
||||
if item.Source == "" {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Message: "source was empty, source is required",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check user scope for this source
|
||||
_, err := d.user.GetScopeForSourceName(item.Source)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Message: fmt.Sprintf("user does not have access: %v", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(item.Source)
|
||||
if idx == nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Message: "source not found",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Get file info
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: idx.MakeIndexPath(item.Path, false),
|
||||
Source: item.Source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
err = files.DeleteFiles(item.Source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Message: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
}
|
||||
// Success
|
||||
response.Succeeded = append(response.Succeeded, item)
|
||||
}
|
||||
|
||||
// Determine status code based on results
|
||||
statusCode := http.StatusOK
|
||||
if len(response.Failed) > 0 {
|
||||
statusCode = http.StatusMultiStatus
|
||||
}
|
||||
|
||||
return renderJSON(w, r, response, statusCode)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -235,23 +409,14 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
if !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
|
||||
}
|
||||
userscope := ""
|
||||
// Determine if this is a directory or file based on trailing slash
|
||||
// Strip trailing slash from userscope to prevent double slashes
|
||||
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
logger.Debugf("error getting scope from source name: %v", err)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
userscope = strings.TrimRight(userscope, "/")
|
||||
path = utils.JoinPathAsUnix(userscope, unescapedPath)
|
||||
// Path is now handled by FileInfoFaster which will apply user scope
|
||||
path = unescapedPath
|
||||
}
|
||||
|
||||
// Determine if this is a directory based on isDir query param or trailing slash (for backwards compatibility)
|
||||
isDirParam := r.URL.Query().Get("isDir")
|
||||
isDir := isDirParam == "true" || strings.HasSuffix(unescapedPath, "/")
|
||||
fileOpts := utils.FileOptions{
|
||||
Username: d.user.Username,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
@@ -284,11 +449,24 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// Directories creation on POST.
|
||||
if isDir {
|
||||
err = files.WriteDirectory(fileOpts)
|
||||
// Get user scope to resolve full index path for directory creation
|
||||
var userScope string
|
||||
userScope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
// Create a new FileOptions with the full index path
|
||||
dirOpts := fileOpts
|
||||
dirOpts.Path = fullIndexPath
|
||||
|
||||
err = files.WriteDirectory(dirOpts)
|
||||
if err != nil {
|
||||
logger.Debugf("error writing directory: %v", err)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
@@ -324,7 +502,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
|
||||
var fileInfo *iteminfo.ExtendedFileInfo
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore)
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore, d.user)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
@@ -379,13 +557,18 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
logger.Debugf("could not move file from %v to %v: %v", tempFilePath, realPath, err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("could not move file from chunked folder to destination: %v", err)
|
||||
}
|
||||
go files.RefreshIndex(source, realPath, false, false) //nolint:errcheck
|
||||
// Refresh index with user scope
|
||||
userScope, scopeErr := d.user.GetScopeForSourceName(source)
|
||||
if scopeErr == nil {
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, fileOpts.Path)
|
||||
go files.RefreshIndex(source, fullIndexPath, false, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore)
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore, d.user)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
@@ -394,7 +577,15 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// If overriding, delete existing thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
}
|
||||
err = files.WriteFile(fileOpts.Source, fileOpts.Path, r.Body)
|
||||
|
||||
// Get user scope to resolve full index path for write operation
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
|
||||
if err != nil {
|
||||
logger.Debugf("error writing file: %v", err)
|
||||
return errToStatus(err), err
|
||||
@@ -436,148 +627,281 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
if strings.HasSuffix(path, "/") {
|
||||
return http.StatusMethodNotAllowed, nil
|
||||
}
|
||||
// Strip trailing slash from userscope to prevent double slashes
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
// Get user scope to resolve full index path for write operation
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
// Check access control for the target path
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
logger.Debugf("user %s denied access to path %s", d.user.Username, path)
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, fullIndexPath, d.user.Username) {
|
||||
logger.Debugf("user %s denied access to path %s", d.user.Username, fullIndexPath)
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
|
||||
err = files.WriteFile(source, utils.JoinPathAsUnix(userscope, path), r.Body)
|
||||
err = files.WriteFile(source, utils.JoinPathAsUnix(userScope, path), r.Body)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
// resourcePatchHandler performs a patch operation (e.g., move, rename) on a resource.
|
||||
// @Summary Patch resource (move/rename)
|
||||
// @Description Moves or renames a resource to a new destination.
|
||||
// resourcePatchHandler performs a patch operation (e.g., move, copy, rename) on resources.
|
||||
// @Summary Move, copy, or rename resources
|
||||
// @Description Performs move, copy, or rename operations on multiple resources. All operations are performed atomically.
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param from query string true "Path from resource in <source_name>::<index_path> format"
|
||||
// @Param destination query string true "Destination path for the resource"
|
||||
// @Param action query string true "Action to perform (copy, rename)"
|
||||
// @Param overwrite query bool false "Overwrite if destination exists"
|
||||
// @Param rename query bool false "Rename if destination exists"
|
||||
// @Success 200 "Resource moved/renamed successfully"
|
||||
// @Param request body MoveCopyRequest true "Move/copy request with items and action"
|
||||
// @Success 200 {object} MoveCopyResponse "All operations completed successfully"
|
||||
// @Success 207 {object} MoveCopyResponse "Partial success - some operations succeeded, some failed"
|
||||
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or parameters"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Resource not found"
|
||||
// @Failure 409 {object} map[string]string "Conflict - Destination exists"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Failure 500 {object} MoveCopyResponse "All operations failed"
|
||||
// @Router /api/resources [patch]
|
||||
func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
action := r.URL.Query().Get("action")
|
||||
if !d.user.Permissions.Modify && d.share == nil {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
|
||||
}
|
||||
|
||||
encodedFrom := r.URL.Query().Get("from")
|
||||
// Decode the URL-encoded path
|
||||
src, err := url.QueryUnescape(encodedFrom)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
dst := r.URL.Query().Get("destination")
|
||||
dst, err = url.QueryUnescape(dst)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
splitSrc := strings.Split(src, "::")
|
||||
if len(splitSrc) <= 1 {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source path: %v", src)
|
||||
}
|
||||
srcIndex := splitSrc[0]
|
||||
src = splitSrc[1]
|
||||
|
||||
splitDst := strings.Split(dst, "::")
|
||||
if len(splitDst) <= 1 {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid destination path: %v", dst)
|
||||
}
|
||||
dstIndex := splitDst[0]
|
||||
dst = splitDst[1]
|
||||
|
||||
if dst == "/" || src == "/" {
|
||||
return http.StatusForbidden, fmt.Errorf("forbidden: source or destination is attempting to modify root")
|
||||
}
|
||||
|
||||
userscopeDst, err := settings.GetScopeFromSourceName(d.user.Scopes, dstIndex)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
userscopeDst = strings.TrimRight(userscopeDst, "/")
|
||||
|
||||
userscopeSrc, err := settings.GetScopeFromSourceName(d.user.Scopes, srcIndex)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
userscopeSrc = strings.TrimRight(userscopeSrc, "/")
|
||||
|
||||
idx := indexing.GetIndex(dstIndex)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", dstIndex)
|
||||
}
|
||||
// check target dir exists
|
||||
parentDir, _, err := idx.GetRealPath(userscopeDst, filepath.Dir(dst))
|
||||
if err != nil {
|
||||
logger.Debugf("Could not get real path for parent dir: %v %v %v", userscopeDst, filepath.Dir(dst), err)
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
realDest := parentDir + "/" + filepath.Base(dst)
|
||||
|
||||
idx2 := indexing.GetIndex(srcIndex)
|
||||
if idx2 == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", srcIndex)
|
||||
}
|
||||
|
||||
realSrc, isSrcDir, err := idx2.GetRealPath(userscopeSrc, src)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
|
||||
// Check access control for both source and destination paths
|
||||
if store.Access != nil {
|
||||
if !store.Access.Permitted(idx2.Path, src, d.user.Username) {
|
||||
logger.Debugf("user %s denied access to source path %s", d.user.Username, src)
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to source path %s", src)
|
||||
}
|
||||
if !store.Access.Permitted(idx.Path, dst, d.user.Username) {
|
||||
logger.Debugf("user %s denied access to destination path %s", d.user.Username, dst)
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to destination path %s", dst)
|
||||
}
|
||||
}
|
||||
rename := r.URL.Query().Get("rename") == "true"
|
||||
if rename {
|
||||
realDest = addVersionSuffix(realDest)
|
||||
}
|
||||
|
||||
// Validate move/rename operation to prevent circular references
|
||||
if action == "rename" || action == "move" {
|
||||
if err = validateMoveOperation(realSrc, realDest, isSrcDir); err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
req, ok := d.Data.(MoveCopyRequest)
|
||||
if req.Action == "" || !ok {
|
||||
// Parse request body
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = patchAction(r.Context(), patchActionParams{
|
||||
action: action,
|
||||
srcIndex: srcIndex,
|
||||
dstIndex: dstIndex,
|
||||
src: realSrc,
|
||||
dst: realDest,
|
||||
d: d,
|
||||
isSrcDir: isSrcDir,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debugf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err)
|
||||
if len(req.Items) == 0 {
|
||||
return http.StatusBadRequest, fmt.Errorf("items array cannot be empty")
|
||||
}
|
||||
return errToStatus(err), err
|
||||
|
||||
if req.Action == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("action is required (copy, move, or rename)")
|
||||
}
|
||||
|
||||
response := MoveCopyResponse{
|
||||
Succeeded: make([]MoveCopyItem, 0),
|
||||
Failed: make([]MoveCopyItem, 0),
|
||||
}
|
||||
|
||||
// Process each item
|
||||
for _, item := range req.Items {
|
||||
// Validate all fields are provided
|
||||
if item.FromSource == "" || item.FromPath == "" || item.ToSource == "" || item.ToPath == "" {
|
||||
item.Message = "fromSource, fromPath, toSource, and toPath are required"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for root path modifications
|
||||
if item.ToPath == "/" || item.FromPath == "/" {
|
||||
item.Message = "cannot modify root directory"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get user scopes for both sources
|
||||
// For shares, paths are already absolute, so use empty scope
|
||||
userscopeSrc := ""
|
||||
userscopeDst := ""
|
||||
if d.share == nil {
|
||||
var err error
|
||||
userscopeSrc, err = d.user.GetScopeForSourceName(item.FromSource)
|
||||
if err != nil {
|
||||
item.Message = "source not available"
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
userscopeDst, err = d.user.GetScopeForSourceName(item.ToSource)
|
||||
if err != nil {
|
||||
item.Message = "destination source not available"
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Get source index
|
||||
srcIdx := indexing.GetIndex(item.FromSource)
|
||||
if srcIdx == nil {
|
||||
item.Message = "source not found"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get destination index
|
||||
dstIdx := indexing.GetIndex(item.ToSource)
|
||||
if dstIdx == nil {
|
||||
item.Message = "destination source not found"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build full index paths for access control
|
||||
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
|
||||
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
|
||||
|
||||
// Check access control for both source and destination paths
|
||||
if !store.Access.Permitted(srcIdx.Path, fullSrcIndexPath, d.user.Username) {
|
||||
item.Message = "access denied to source path"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
if !store.Access.Permitted(dstIdx.Path, fullDstIndexPath, d.user.Username) {
|
||||
item.Message = "access denied to destination path"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get real paths
|
||||
realSrc, isSrcDir, err := srcIdx.GetRealPath(userscopeSrc, item.FromPath)
|
||||
if err != nil {
|
||||
logger.Errorf("could not resolve source path: %v, item.FromPath: %v", err, item.FromPath)
|
||||
item.Message = "could not resolve source path"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check destination parent directory exists
|
||||
parentDir, _, err := dstIdx.GetRealPath(userscopeDst, filepath.Dir(item.ToPath))
|
||||
if err != nil {
|
||||
item.Message = "destination directory does not exist"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
realDest := parentDir + "/" + filepath.Base(item.ToPath)
|
||||
|
||||
// Auto-rename if requested
|
||||
if req.Rename {
|
||||
realDest = addVersionSuffix(realDest)
|
||||
}
|
||||
|
||||
// Validate move/rename operation to prevent circular references
|
||||
if req.Action == "rename" || req.Action == "move" {
|
||||
if err = validateMoveOperation(realSrc, realDest, isSrcDir); err != nil {
|
||||
item.Message = "invalid move operation, circular reference"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the action
|
||||
err = patchAction(r.Context(), patchActionParams{
|
||||
action: req.Action,
|
||||
srcIndex: item.FromSource,
|
||||
dstIndex: item.ToSource,
|
||||
src: realSrc,
|
||||
dst: realDest,
|
||||
d: d,
|
||||
isSrcDir: isSrcDir,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err)
|
||||
if d.share != nil {
|
||||
item.Message = "could not run patch action"
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
item.Message = err.Error()
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Success
|
||||
response.Succeeded = append(response.Succeeded, item)
|
||||
}
|
||||
|
||||
if len(response.Failed) == 0 && len(response.Succeeded) == 0 {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: "no operations performed",
|
||||
})
|
||||
}
|
||||
|
||||
// For shares, sanitize the response to only include messages (hide paths)
|
||||
if d.share != nil {
|
||||
sanitizedFailed := make([]MoveCopyItem, len(response.Failed))
|
||||
for i, item := range response.Failed {
|
||||
sanitizedFailed[i] = MoveCopyItem{
|
||||
Message: item.Message,
|
||||
}
|
||||
}
|
||||
response.Failed = sanitizedFailed
|
||||
|
||||
// Clear succeeded items details for shares (only keep count implicitly via array length)
|
||||
sanitizedSucceeded := make([]MoveCopyItem, len(response.Succeeded))
|
||||
response.Succeeded = sanitizedSucceeded
|
||||
}
|
||||
|
||||
// Determine status code based on results
|
||||
statusCode := http.StatusOK
|
||||
if len(response.Failed) > 0 && len(response.Succeeded) == 0 {
|
||||
// All operations failed - return 500 error
|
||||
statusCode = http.StatusInternalServerError
|
||||
} else if len(response.Failed) > 0 && len(response.Succeeded) > 0 {
|
||||
// Some succeeded, some failed - return 207 multi-status
|
||||
statusCode = http.StatusMultiStatus
|
||||
}
|
||||
// If all succeeded, statusCode remains 200 OK
|
||||
return renderJSON(w, r, response, statusCode)
|
||||
}
|
||||
|
||||
func addVersionSuffix(source string) string {
|
||||
@@ -613,18 +937,14 @@ func patchAction(ctx context.Context, params patchActionParams) error {
|
||||
return err
|
||||
case "rename", "move":
|
||||
idx := indexing.GetIndex(params.srcIndex)
|
||||
srcPath := idx.MakeIndexPath(params.src)
|
||||
if !params.isSrcDir {
|
||||
srcPath = strings.TrimSuffix(srcPath, "/")
|
||||
}
|
||||
srcPath := idx.MakeIndexPath(params.src, params.isSrcDir)
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Username: params.d.user.Username,
|
||||
FollowSymlinks: true,
|
||||
Path: srcPath,
|
||||
Source: params.srcIndex,
|
||||
IsDir: params.isSrcDir,
|
||||
ShowHidden: params.d.user.ShowHidden,
|
||||
}, store.Access)
|
||||
}, store.Access, params.d.user)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+84
-34
@@ -14,9 +14,9 @@ import (
|
||||
|
||||
type searchOptions struct {
|
||||
query string
|
||||
source string
|
||||
sources []string
|
||||
searchScope string
|
||||
combinedPath string
|
||||
combinedPath map[string]string // source -> combinedPath
|
||||
sessionId string
|
||||
largest bool
|
||||
}
|
||||
@@ -28,42 +28,54 @@ type searchOptions struct {
|
||||
// against the file index, which is built from the root directory specified in
|
||||
// the server's configuration. The results are filtered based on the user's scope.
|
||||
//
|
||||
// The handler supports searching a single source (using the 'source' parameter)
|
||||
// or multiple sources (using the 'sources' parameter). When multiple sources
|
||||
// are specified, the scope is always set to the user's scope for each source.
|
||||
//
|
||||
// The handler expects the following headers in the request:
|
||||
// - SessionId: A unique identifier for the user's session.
|
||||
// - UserScope: The scope of the user, which influences the search context.
|
||||
//
|
||||
// The request URL should include a query parameter named `query` that specifies
|
||||
// the search terms to use. The response will include an array of searchResponse objects
|
||||
// containing the path, type, and dir status.
|
||||
// The request URL should include query parameters:
|
||||
// - query: The search terms to use (required)
|
||||
// - source: Source name (deprecated, use 'sources' instead)
|
||||
// - sources: Comma-separated list of source names (e.g., "source1,source2")
|
||||
// - scope: Optional path within user scope to search
|
||||
//
|
||||
// Example request:
|
||||
// Example request (single source):
|
||||
//
|
||||
// GET api/search?query=myfile
|
||||
// GET api/search?query=myfile&source=mysource
|
||||
//
|
||||
// Example request (multiple sources):
|
||||
//
|
||||
// GET api/search?query=myfile&sources=source1,source2
|
||||
//
|
||||
// Example response:
|
||||
// [
|
||||
//
|
||||
// {
|
||||
// "path": "/path/to/myfile.txt",
|
||||
// "type": "text"
|
||||
// "type": "text",
|
||||
// "source": "mysource"
|
||||
// },
|
||||
// {
|
||||
// "path": "/path/to/mydir/",
|
||||
// "type": "directory"
|
||||
// "type": "directory",
|
||||
// "source": "mysource"
|
||||
// }
|
||||
//
|
||||
// ]
|
||||
//
|
||||
// @Summary Search Files
|
||||
// @Description Searches for files matching the provided query. Returns file paths and metadata based on the user's session and scope.
|
||||
// @Description Searches for files matching the provided query. Returns file paths and metadata based on the user's session and scope. Supports searching across multiple sources when using the 'sources' parameter.
|
||||
// @Tags Search
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param query query string true "Search query"
|
||||
// @Param source query string true "Source name for the desired source"
|
||||
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only"
|
||||
// @Param source query string false "Source name for the desired source (deprecated, use 'sources' instead)"
|
||||
// @Param sources query string false "Comma-separated list of source names to search across multiple sources. When multiple sources are specified, scope is always the user's scope for each source."
|
||||
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only. Ignored when multiple sources are specified."
|
||||
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
|
||||
// @Success 200 {array} indexing.SearchResult "List of search results"
|
||||
// @Success 200 {array} indexing.SearchResult "List of search results with source field populated"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Router /api/search [get]
|
||||
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -71,23 +83,32 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
index := indexing.GetIndex(searchOptions.source)
|
||||
if index == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.source)
|
||||
|
||||
var response []*indexing.SearchResult
|
||||
if len(searchOptions.sources) == 1 {
|
||||
// Single source - use the existing Search method for backward compatibility
|
||||
index := indexing.GetIndex(searchOptions.sources[0])
|
||||
if index == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.sources[0])
|
||||
}
|
||||
combinedPath := searchOptions.combinedPath[searchOptions.sources[0]]
|
||||
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
|
||||
} else {
|
||||
// Multiple sources - use the new SearchMultiSources function
|
||||
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
|
||||
}
|
||||
|
||||
// Perform the search using the provided query and user scope
|
||||
response := index.Search(searchOptions.query, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
|
||||
|
||||
// Filter out items that are not permitted according to access rules
|
||||
// Filter out items that are not permitted according to access rules and trim user scope from paths
|
||||
filteredResponse := make([]*indexing.SearchResult, 0, len(response))
|
||||
for _, result := range response {
|
||||
indexPath := utils.JoinPathAsUnix(searchOptions.combinedPath, result.Path)
|
||||
index := indexing.GetIndex(result.Source)
|
||||
combinedPath := searchOptions.combinedPath[result.Source]
|
||||
indexPath := utils.JoinPathAsUnix(combinedPath, result.Path)
|
||||
if store.Access != nil && !store.Access.Permitted(index.Path, indexPath, d.user.Username) {
|
||||
continue // Silently skip this file/folder
|
||||
}
|
||||
// Remove the user scope from the path (modifying in place is safe - these are fresh allocations)
|
||||
result.Path = strings.TrimPrefix(result.Path, searchOptions.combinedPath)
|
||||
result.Path = strings.TrimPrefix(result.Path, combinedPath)
|
||||
if result.Path == "" {
|
||||
result.Path = "/"
|
||||
}
|
||||
@@ -98,9 +119,28 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
|
||||
func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, error) {
|
||||
query := r.URL.Query().Get("query")
|
||||
source := r.URL.Query().Get("source")
|
||||
sourcesParam := r.URL.Query().Get("sources")
|
||||
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
|
||||
scope := r.URL.Query().Get("scope")
|
||||
largest := r.URL.Query().Get("largest") == "true"
|
||||
|
||||
var sources []string
|
||||
if sourcesParam != "" {
|
||||
sources = strings.Split(sourcesParam, ",")
|
||||
} else if sourceParam != "" {
|
||||
sources = []string{sourceParam}
|
||||
} else {
|
||||
return nil, fmt.Errorf("either 'source' or 'sources' query parameter is required")
|
||||
}
|
||||
|
||||
// Validate all sources exist
|
||||
for _, source := range sources {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return nil, fmt.Errorf("index not found for source %s", source)
|
||||
}
|
||||
}
|
||||
|
||||
unencodedScope, err := url.PathUnescape(scope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid path encoding: %v", err)
|
||||
@@ -109,22 +149,32 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
|
||||
return nil, fmt.Errorf("query is too short, minimum length is %d", settings.Config.Server.MinSearchLength)
|
||||
}
|
||||
searchScope := strings.TrimPrefix(unencodedScope, ".")
|
||||
|
||||
// If multiple sources, always use user scope (ignore searchScope)
|
||||
if len(sources) > 1 {
|
||||
searchScope = ""
|
||||
}
|
||||
|
||||
// Retrieve the User-Agent and X-Auth headers from the request
|
||||
sessionId := r.Header.Get("SessionId")
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return nil, fmt.Errorf("index not found for source %s", source)
|
||||
|
||||
// Build combinedPath map for each source
|
||||
combinedPathMap := make(map[string]string)
|
||||
for _, source := range sources {
|
||||
index := indexing.GetIndex(source)
|
||||
userscope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope), true) // searchScope is a directory
|
||||
combinedPathMap[source] = combinedPath
|
||||
}
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope))
|
||||
|
||||
return &searchOptions{
|
||||
query: query,
|
||||
source: source,
|
||||
sources: sources,
|
||||
searchScope: searchScope,
|
||||
combinedPath: combinedPath,
|
||||
combinedPath: combinedPathMap,
|
||||
sessionId: sessionId,
|
||||
largest: largest,
|
||||
}, nil
|
||||
|
||||
+14
-5
@@ -17,7 +17,6 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
@@ -66,7 +65,6 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
|
||||
s.CommonShare.HasPassword = s.HasPassword()
|
||||
s.DownloadURL = getShareURL(r, s.Hash, true, s.Token)
|
||||
s.ShareURL = getShareURL(r, s.Hash, false, s.Token)
|
||||
|
||||
// Create response with source name (overrides the embedded Link's source field)
|
||||
responses = append(responses, &ShareResponse{
|
||||
Link: s,
|
||||
@@ -129,7 +127,7 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if !ok {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", sourceName)
|
||||
}
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, sourceName)
|
||||
userscope, err := d.user.GetScopeForSourceName(sourceName)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
@@ -360,7 +358,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if idx == nil {
|
||||
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", body.Source)
|
||||
}
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source.Name)
|
||||
userscope, err := d.user.GetScopeForSourceName(source.Name)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
@@ -372,6 +370,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, fmt.Errorf("path not found: %s", providedPath)
|
||||
}
|
||||
|
||||
if body.ShareType == "upload" && !body.AllowCreate {
|
||||
body.AllowCreate = true
|
||||
}
|
||||
@@ -445,7 +444,7 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
|
||||
}
|
||||
|
||||
// Get user scope for this source
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
userscope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
@@ -617,6 +616,16 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
commonShare := shareLink.CommonShare
|
||||
commonShare.DownloadURL = getShareURL(r, hash, true, shareLink.Token)
|
||||
commonShare.ShareURL = getShareURL(r, hash, false, shareLink.Token)
|
||||
_, _, err = getShareImagePartsHelper(shareLink, true)
|
||||
if err == nil {
|
||||
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
|
||||
}
|
||||
_, _, err = getShareImagePartsHelper(shareLink, false)
|
||||
if err == nil {
|
||||
commonShare.FaviconUrl = fmt.Sprintf("%spublic/api/share/image?favicon=true&hash=%s", config.Server.BaseURL, hash)
|
||||
}
|
||||
commonShare.Source = ""
|
||||
commonShare.Path = ""
|
||||
return renderJSON(w, r, commonShare)
|
||||
}
|
||||
|
||||
|
||||
+68
-19
@@ -73,10 +73,12 @@ 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 + "/img/icons/android-chrome-512x512.png"
|
||||
disableSidebar := false
|
||||
|
||||
// Use custom favicon if configured and validated, otherwise fall back to default
|
||||
favicon := staticURL + "/favicon"
|
||||
shareHash := ""
|
||||
data := make(map[string]interface{})
|
||||
disableNavButtons := settings.Config.Frontend.DisableNavButtons
|
||||
if d.share != nil && d.shareValid {
|
||||
@@ -99,32 +101,78 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
userSelectedTheme = theme.CssRaw
|
||||
}
|
||||
}
|
||||
if d.share.ShareTheme != "" {
|
||||
theme, ok := config.Frontend.Styling.CustomThemeOptions[d.share.ShareTheme]
|
||||
if ok {
|
||||
userSelectedTheme = theme.CssRaw
|
||||
}
|
||||
}
|
||||
if d.share.DisableSidebar {
|
||||
disableSidebar = true
|
||||
}
|
||||
if strings.HasPrefix(d.share.Banner, "http") {
|
||||
banner = d.share.Banner
|
||||
} else {
|
||||
_, _, err := getShareImagePartsHelper(d.share, true)
|
||||
if err == nil {
|
||||
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(d.share.Favicon, "http") {
|
||||
favicon = d.share.Favicon
|
||||
} else {
|
||||
_, _, err := getShareImagePartsHelper(d.share, false)
|
||||
if err == nil {
|
||||
favicon = fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
|
||||
}
|
||||
}
|
||||
shareHash = d.share.Hash
|
||||
}
|
||||
// Set login icon URL
|
||||
loginIcon := staticURL + "/loginIcon"
|
||||
|
||||
// Load loading spinners CSS from static files
|
||||
loadingSpinnersCSS := ""
|
||||
cssPath := "static/css/loadingSpinners.css"
|
||||
cssContent, err := fs.ReadFile(assetFs, cssPath)
|
||||
if err == nil {
|
||||
loadingSpinnersCSS = string(cssContent)
|
||||
}
|
||||
|
||||
// Determine OpenGraph image: use banner if set, otherwise use dedicated OG image or fallback to app icon
|
||||
ogImage := banner
|
||||
if banner == staticURL+"/img/icons/android-chrome-512x512.png" {
|
||||
// No custom banner set - check if dedicated OG image exists, otherwise use 512x512 icon
|
||||
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) for optimal display
|
||||
ogImage = staticURL + "/img/icons/android-chrome-512x512.png"
|
||||
}
|
||||
|
||||
// Construct the full URL for the current request
|
||||
var fullURL string
|
||||
if config.Server.ExternalUrl != "" {
|
||||
// ExternalUrl already includes schema (e.g., http://mydomain.com)
|
||||
fullURL = strings.TrimSuffix(config.Server.ExternalUrl, "/") + r.URL.Path
|
||||
} else {
|
||||
// Build URL from request
|
||||
scheme := "http"
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
fullURL = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path)
|
||||
}
|
||||
|
||||
data["htmlVars"] = map[string]interface{}{
|
||||
"title": title,
|
||||
"customCSS": config.Frontend.Styling.CustomCSSRaw,
|
||||
"userSelectedTheme": userSelectedTheme,
|
||||
"lightBackground": config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": config.Frontend.Styling.DarkBackground,
|
||||
"staticURL": staticURL,
|
||||
"baseURL": config.Server.BaseURL,
|
||||
"favicon": favicon,
|
||||
"loginIcon": loginIcon,
|
||||
"color": defaultThemeColor,
|
||||
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
|
||||
"appIcon": staticURL + "/img/icons/android-chrome-256x256.png",
|
||||
"description": description,
|
||||
"title": title,
|
||||
"customCSS": config.Frontend.Styling.CustomCSSRaw,
|
||||
"userSelectedTheme": userSelectedTheme,
|
||||
"lightBackground": config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": config.Frontend.Styling.DarkBackground,
|
||||
"staticURL": staticURL,
|
||||
"baseURL": config.Server.BaseURL,
|
||||
"favicon": favicon,
|
||||
"loginIcon": loginIcon,
|
||||
"color": defaultThemeColor,
|
||||
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
|
||||
"appIcon": staticURL + "/img/icons/android-chrome-512x512.png",
|
||||
"description": description,
|
||||
"loadingSpinnersCSS": loadingSpinnersCSS,
|
||||
"banner": banner,
|
||||
"image": ogImage,
|
||||
"url": fullURL,
|
||||
}
|
||||
// variables consumed by frontend as json
|
||||
data["globalVars"] = map[string]interface{}{
|
||||
@@ -153,6 +201,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"eventBasedThemes": !config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
}
|
||||
|
||||
// Marshal each variable to JSON strings for direct template usage
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
@@ -90,8 +89,8 @@ func prepForFrontend(u *users.User) {
|
||||
u.OtpEnabled = u.TOTPSecret != ""
|
||||
u.TOTPSecret = ""
|
||||
u.TOTPNonce = ""
|
||||
u.Scopes = settings.ConvertToFrontendScopes(u.Scopes)
|
||||
u.SidebarLinks = settings.ConvertToFrontendSidebarLinks(u.SidebarLinks)
|
||||
u.Scopes = u.GetFrontendScopes()
|
||||
u.SidebarLinks = u.GetFrontendSidebarLinks()
|
||||
}
|
||||
|
||||
// userDeleteHandler deletes a user by ID.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/net/webdav"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func createWebDAVHandler(prefix string) handleFunc {
|
||||
var locks = webdav.NewMemLS()
|
||||
// webdavHandler serves the webdav requests.
|
||||
return func(writer http.ResponseWriter, request *http.Request, d *requestContext) (int, error) {
|
||||
fullPath := request.PathValue("path")
|
||||
source := request.PathValue("scope")
|
||||
if !userHasReadWriteAccess(d) {
|
||||
// (reddec): we're currently allowing WebDAV access only for users with R/W access for simplicity.
|
||||
// in the follow-ups, we need to add mapping of webdav operations and permissions (which is trickier than it looks like).
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
|
||||
}
|
||||
userscope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
logger.Debugf("error getting scope from source name: %v", err)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
virtualPath := utils.JoinPathAsUnix(userscope, fullPath)
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
logger.Debugf("source %s not found", source)
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
|
||||
realPath, _, _ := idx.GetRealPath(virtualPath)
|
||||
scopePath, _, _ := idx.GetRealPath(userscope)
|
||||
|
||||
// checking only base (scope) path, as per-item permission is currently impossible to implement.
|
||||
// however, when we are granting an access to the user, we are granting it per-folder, so everything should be fine.
|
||||
// TODO: properly handle symlink and hidden files in requests
|
||||
info, err := idx.GetFsInfo(userscope, false, true)
|
||||
if err != nil {
|
||||
logger.Debugf("error getting dir info: %v", err)
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
|
||||
err = store.Access.CheckChildItemAccess(&iteminfo.ExtendedFileInfo{
|
||||
FileInfo: *info,
|
||||
Source: source,
|
||||
RealPath: scopePath,
|
||||
}, idx, d.user.Username)
|
||||
if err != nil {
|
||||
logger.Debugf("error checking child item access: %v", err)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
logger.Debugf("webdav: method=%s, request=%s, source=%s, path=%s, virtual_path=%s, real_path=%s, scope_path=%s", request.Method, request.URL.Path, source, fullPath, virtualPath, realPath, scopePath)
|
||||
|
||||
wd := &webdav.Handler{
|
||||
Prefix: prefix + "/" + source,
|
||||
FileSystem: webdav.Dir(scopePath),
|
||||
LockSystem: locks,
|
||||
Logger: func(request *http.Request, err error) {
|
||||
if err != nil {
|
||||
logger.Errorf("webdav handler failed on path %s: %s", request.URL.Path, err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
wd.ServeHTTP(writer, request)
|
||||
return 0, nil // errors and responses (XML-formatted) are handled by webdav handler
|
||||
}
|
||||
}
|
||||
func userHasReadWriteAccess(d *requestContext) bool {
|
||||
return d.user.Permissions.Create && d.user.Permissions.Delete && d.user.Permissions.Modify && d.user.Permissions.Download
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package indexing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
@@ -18,51 +19,81 @@ func setupConditionalTestIndex(conditionals settings.ConditionalFilter) *Index {
|
||||
}
|
||||
|
||||
// Build the conditional maps (this is normally done during config loading)
|
||||
source.Config.ResolvedConditionals = &settings.ResolvedConditionalsConfig{
|
||||
FileNames: make(map[string]settings.ConditionalIndexConfig),
|
||||
FolderNames: make(map[string]settings.ConditionalIndexConfig),
|
||||
FilePaths: make(map[string]settings.ConditionalIndexConfig),
|
||||
FolderPaths: make(map[string]settings.ConditionalIndexConfig),
|
||||
NeverWatchPaths: make(map[string]struct{}),
|
||||
IncludeRootItems: make(map[string]struct{}),
|
||||
source.Config.ResolvedRules = settings.ResolvedRulesConfig{
|
||||
FileNames: make(map[string]settings.ConditionalRule),
|
||||
FolderNames: make(map[string]settings.ConditionalRule),
|
||||
FilePaths: make(map[string]settings.ConditionalRule),
|
||||
FolderPaths: make(map[string]settings.ConditionalRule),
|
||||
NeverWatchPaths: make(map[string]struct{}),
|
||||
IncludeRootItems: make(map[string]struct{}),
|
||||
IgnoreAllHidden: false,
|
||||
IgnoreAllZeroSizeFolders: false,
|
||||
IgnoreAllSymlinks: false,
|
||||
IndexingDisabled: false,
|
||||
}
|
||||
|
||||
// Backwards compatibility: if old format fields are set, treat as global rules
|
||||
if conditionals.IgnoreHidden {
|
||||
source.Config.ResolvedRules.IgnoreAllHidden = true
|
||||
}
|
||||
if conditionals.ZeroSizeFolders {
|
||||
source.Config.ResolvedRules.IgnoreAllZeroSizeFolders = true
|
||||
}
|
||||
|
||||
// Build maps from ItemRules - match the real setConditionals implementation
|
||||
resolved := source.Config.ResolvedConditionals
|
||||
for _, rule := range conditionals.ItemRules {
|
||||
// Check if this is a root-level rule (folderPath == "/")
|
||||
// Root-level rules with ignoreHidden/ignoreZeroSizeFolders/viewable set global flags
|
||||
isRootLevelRule := rule.FolderPath == "/"
|
||||
|
||||
// Infer global flags from root-level rules
|
||||
if isRootLevelRule {
|
||||
if rule.IgnoreHidden {
|
||||
source.Config.ResolvedRules.IgnoreAllHidden = true
|
||||
}
|
||||
if rule.IgnoreSymlinks {
|
||||
source.Config.ResolvedRules.IgnoreAllSymlinks = true
|
||||
}
|
||||
if rule.IgnoreZeroSizeFolders {
|
||||
source.Config.ResolvedRules.IgnoreAllZeroSizeFolders = true
|
||||
}
|
||||
if rule.Viewable {
|
||||
source.Config.ResolvedRules.IndexingDisabled = true
|
||||
}
|
||||
}
|
||||
// Note: FileNames and FolderNames are NOT populated from rules in the real implementation
|
||||
// They remain empty maps (unused in current implementation)
|
||||
|
||||
// Handle exact path matches
|
||||
if rule.FilePath != "" {
|
||||
resolved.FilePaths[rule.FilePath] = rule
|
||||
source.Config.ResolvedRules.FilePaths[rule.FilePath] = rule
|
||||
}
|
||||
if rule.FolderPath != "" {
|
||||
resolved.FolderPaths[rule.FolderPath] = rule
|
||||
source.Config.ResolvedRules.FolderPaths[rule.FolderPath] = rule
|
||||
}
|
||||
|
||||
// Handle StartsWith/EndsWith (stored in slices)
|
||||
if rule.FileEndsWith != "" {
|
||||
resolved.FileEndsWith = append(resolved.FileEndsWith, rule)
|
||||
source.Config.ResolvedRules.FileEndsWith = append(source.Config.ResolvedRules.FileEndsWith, rule)
|
||||
}
|
||||
if rule.FolderEndsWith != "" {
|
||||
resolved.FolderEndsWith = append(resolved.FolderEndsWith, rule)
|
||||
source.Config.ResolvedRules.FolderEndsWith = append(source.Config.ResolvedRules.FolderEndsWith, rule)
|
||||
}
|
||||
if rule.FileStartsWith != "" {
|
||||
resolved.FileStartsWith = append(resolved.FileStartsWith, rule)
|
||||
source.Config.ResolvedRules.FileStartsWith = append(source.Config.ResolvedRules.FileStartsWith, rule)
|
||||
}
|
||||
if rule.FolderStartsWith != "" {
|
||||
resolved.FolderStartsWith = append(resolved.FolderStartsWith, rule)
|
||||
source.Config.ResolvedRules.FolderStartsWith = append(source.Config.ResolvedRules.FolderStartsWith, rule)
|
||||
}
|
||||
|
||||
// Handle NeverWatchPath
|
||||
if rule.NeverWatchPath != "" {
|
||||
resolved.NeverWatchPaths[rule.NeverWatchPath] = struct{}{}
|
||||
source.Config.ResolvedRules.NeverWatchPaths[rule.NeverWatchPath] = struct{}{}
|
||||
}
|
||||
|
||||
// Handle IncludeRootItem
|
||||
if rule.IncludeRootItem != "" {
|
||||
resolved.IncludeRootItems[rule.IncludeRootItem] = struct{}{}
|
||||
source.Config.ResolvedRules.IncludeRootItems[rule.IncludeRootItem] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +157,12 @@ func TestShouldSkip_FolderStartsWith(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{
|
||||
ItemRules: []settings.ConditionalIndexConfig{
|
||||
ItemRules: []settings.ConditionalRule{
|
||||
{FolderStartsWith: tt.ruleValue},
|
||||
},
|
||||
})
|
||||
|
||||
result := idx.shouldSkip(true, false, tt.fullPath, tt.baseName, actionConfig{})
|
||||
result := idx.ShouldSkip(true, tt.fullPath, false, false, false)
|
||||
if result != tt.shouldSkip {
|
||||
t.Errorf("%s: expected shouldSkip=%v, got %v (fullPath=%s, baseName=%s, rule=%s)",
|
||||
tt.description, tt.shouldSkip, result, tt.fullPath, tt.baseName, tt.ruleValue)
|
||||
@@ -174,15 +205,15 @@ func TestShouldSkip_FolderNames(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{
|
||||
ItemRules: []settings.ConditionalIndexConfig{
|
||||
ItemRules: []settings.ConditionalRule{
|
||||
{FolderNames: tt.ruleValue},
|
||||
},
|
||||
})
|
||||
|
||||
// Manually populate FolderNames map since setConditionals doesn't do it
|
||||
idx.Config.ResolvedConditionals.FolderNames[tt.ruleValue] = settings.ConditionalIndexConfig{FolderNames: tt.ruleValue}
|
||||
idx.Config.ResolvedRules.FolderNames[tt.ruleValue] = settings.ConditionalRule{FolderNames: tt.ruleValue}
|
||||
|
||||
result := idx.shouldSkip(true, false, "/"+tt.baseName+"/", tt.baseName, actionConfig{})
|
||||
result := idx.ShouldSkip(true, "/"+tt.baseName+"/", false, false, false)
|
||||
if result != tt.shouldSkip {
|
||||
t.Errorf("%s: expected shouldSkip=%v, got %v (baseName=%s, rule=%s)",
|
||||
tt.description, tt.shouldSkip, result, tt.baseName, tt.ruleValue)
|
||||
@@ -237,12 +268,12 @@ func TestShouldSkip_FolderPaths(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{
|
||||
ItemRules: []settings.ConditionalIndexConfig{
|
||||
ItemRules: []settings.ConditionalRule{
|
||||
{FolderPath: tt.ruleValue},
|
||||
},
|
||||
})
|
||||
|
||||
result := idx.shouldSkip(true, false, tt.fullPath, tt.baseName, actionConfig{})
|
||||
result := idx.ShouldSkip(true, tt.fullPath, false, false, false)
|
||||
if result != tt.shouldSkip {
|
||||
t.Errorf("%s: expected shouldSkip=%v, got %v (fullPath=%s, baseName=%s, rule=%s)",
|
||||
tt.description, tt.shouldSkip, result, tt.fullPath, tt.baseName, tt.ruleValue)
|
||||
@@ -285,12 +316,12 @@ func TestShouldSkip_FileStartsWith(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{
|
||||
ItemRules: []settings.ConditionalIndexConfig{
|
||||
ItemRules: []settings.ConditionalRule{
|
||||
{FileStartsWith: tt.ruleValue},
|
||||
},
|
||||
})
|
||||
|
||||
result := idx.shouldSkip(false, false, "/"+tt.baseName, tt.baseName, actionConfig{})
|
||||
result := idx.ShouldSkip(false, "/"+tt.baseName, false, false, false)
|
||||
if result != tt.shouldSkip {
|
||||
t.Errorf("%s: expected shouldSkip=%v, got %v (baseName=%s, rule=%s)",
|
||||
tt.description, tt.shouldSkip, result, tt.baseName, tt.ruleValue)
|
||||
@@ -333,12 +364,12 @@ func TestShouldSkip_FileEndsWith(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{
|
||||
ItemRules: []settings.ConditionalIndexConfig{
|
||||
ItemRules: []settings.ConditionalRule{
|
||||
{FileEndsWith: tt.ruleValue},
|
||||
},
|
||||
})
|
||||
|
||||
result := idx.shouldSkip(false, false, "/"+tt.baseName, tt.baseName, actionConfig{})
|
||||
result := idx.ShouldSkip(false, "/"+tt.baseName, false, false, false)
|
||||
if result != tt.shouldSkip {
|
||||
t.Errorf("%s: expected shouldSkip=%v, got %v (baseName=%s, rule=%s)",
|
||||
tt.description, tt.shouldSkip, result, tt.baseName, tt.ruleValue)
|
||||
@@ -385,12 +416,12 @@ func TestShouldSkip_FilePaths(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{
|
||||
ItemRules: []settings.ConditionalIndexConfig{
|
||||
ItemRules: []settings.ConditionalRule{
|
||||
{FilePath: tt.ruleValue},
|
||||
},
|
||||
})
|
||||
|
||||
result := idx.shouldSkip(false, false, tt.fullPath, tt.baseName, actionConfig{})
|
||||
result := idx.ShouldSkip(false, tt.fullPath, false, false, false)
|
||||
if result != tt.shouldSkip {
|
||||
t.Errorf("%s: expected shouldSkip=%v, got %v (fullPath=%s, rule=%s)",
|
||||
tt.description, tt.shouldSkip, result, tt.fullPath, tt.ruleValue)
|
||||
@@ -403,7 +434,7 @@ func TestShouldSkip_FileInExcludedFolderPath(t *testing.T) {
|
||||
// Test that files in excluded folder paths are also skipped
|
||||
// Uses FilePaths rule which applies to both the folder and its files via prefix matching
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{
|
||||
ItemRules: []settings.ConditionalIndexConfig{
|
||||
ItemRules: []settings.ConditionalRule{
|
||||
{FilePath: "/graham/"},
|
||||
},
|
||||
})
|
||||
@@ -420,7 +451,7 @@ func TestShouldSkip_FileInExcludedFolderPath(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := idx.shouldSkip(false, false, tt.fullPath, tt.baseName, actionConfig{})
|
||||
result := idx.ShouldSkip(false, tt.fullPath, false, false, false)
|
||||
if result != tt.shouldSkip {
|
||||
t.Errorf("File %s (%s): expected shouldSkip=%v, got %v",
|
||||
tt.fullPath, tt.reason, tt.shouldSkip, result)
|
||||
@@ -431,7 +462,7 @@ func TestShouldSkip_FileInExcludedFolderPath(t *testing.T) {
|
||||
func TestShouldSkip_MultipleRules(t *testing.T) {
|
||||
// Test that multiple rules work together
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{
|
||||
ItemRules: []settings.ConditionalIndexConfig{
|
||||
ItemRules: []settings.ConditionalRule{
|
||||
{FolderStartsWith: "temp"},
|
||||
{FolderStartsWith: "tmp"},
|
||||
{FolderNames: "node_modules"},
|
||||
@@ -443,8 +474,8 @@ func TestShouldSkip_MultipleRules(t *testing.T) {
|
||||
})
|
||||
|
||||
// Manually populate FolderNames map
|
||||
idx.Config.ResolvedConditionals.FolderNames["node_modules"] = settings.ConditionalIndexConfig{FolderNames: "node_modules"}
|
||||
idx.Config.ResolvedConditionals.FolderNames["@eaDir"] = settings.ConditionalIndexConfig{FolderNames: "@eaDir"}
|
||||
idx.Config.ResolvedRules.FolderNames["node_modules"] = settings.ConditionalRule{FolderNames: "node_modules"}
|
||||
idx.Config.ResolvedRules.FolderNames["@eaDir"] = settings.ConditionalRule{FolderNames: "@eaDir"}
|
||||
|
||||
tests := []struct {
|
||||
isDir bool
|
||||
@@ -468,7 +499,7 @@ func TestShouldSkip_MultipleRules(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := idx.shouldSkip(tt.isDir, false, tt.fullPath, tt.baseName, actionConfig{})
|
||||
result := idx.ShouldSkip(tt.isDir, tt.fullPath, false, false, false)
|
||||
if result != tt.shouldSkip {
|
||||
t.Errorf("%s (%s): expected shouldSkip=%v, got %v",
|
||||
tt.baseName, tt.reason, tt.shouldSkip, result)
|
||||
@@ -480,13 +511,13 @@ func TestShouldSkip_ViewableStillSkips(t *testing.T) {
|
||||
// Test that Viewable:true doesn't affect shouldSkip behavior
|
||||
// The Viewable field is checked separately in IsViewable(), not in shouldSkip()
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{
|
||||
ItemRules: []settings.ConditionalIndexConfig{
|
||||
ItemRules: []settings.ConditionalRule{
|
||||
{FolderStartsWith: "important", Viewable: true}, // Viewable but still skipped from indexing
|
||||
},
|
||||
})
|
||||
|
||||
// shouldSkip still returns true even with Viewable:true (it's checked separately)
|
||||
result := idx.shouldSkip(true, false, "/important/", "important", actionConfig{})
|
||||
// ShouldSkip still returns true even with Viewable:true (it's checked separately)
|
||||
result := idx.ShouldSkip(true, "/important/", false, false, false)
|
||||
if result != true {
|
||||
t.Errorf("Folder should be skipped (Viewable is checked separately), got shouldSkip=%v", result)
|
||||
}
|
||||
@@ -498,79 +529,97 @@ func TestShouldSkip_HiddenFiles(t *testing.T) {
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
isHidden bool
|
||||
path string
|
||||
shouldSkip bool
|
||||
}{
|
||||
{true, true},
|
||||
{false, false},
|
||||
{"/.hiddenfile", true}, // Hidden file (starts with .)
|
||||
{"/file.txt", false}, // Regular file
|
||||
{"/.hidden/file.txt", false}, // File in hidden directory (but file itself isn't hidden)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := idx.shouldSkip(false, tt.isHidden, "/file.txt", "file.txt", actionConfig{})
|
||||
// For hidden file test, extract if the path starts with "/."
|
||||
isHiddenPath := IsHidden(tt.path)
|
||||
result := idx.ShouldSkip(false, tt.path, isHiddenPath, false, false)
|
||||
if result != tt.shouldSkip {
|
||||
t.Errorf("isHidden=%v: expected shouldSkip=%v, got %v",
|
||||
tt.isHidden, tt.shouldSkip, result)
|
||||
t.Errorf("path=%s: expected shouldSkip=%v, got %v",
|
||||
tt.path, tt.shouldSkip, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkip_NeverWatch(t *testing.T) {
|
||||
// Test NeverWatch functionality - folders should be skipped during routine scans
|
||||
func TestIsNeverWatchPath(t *testing.T) {
|
||||
// Test NeverWatchPath functionality - paths indexed once, then never re-indexed
|
||||
idx := setupConditionalTestIndex(settings.ConditionalFilter{
|
||||
ItemRules: []settings.ConditionalIndexConfig{
|
||||
{NeverWatchPath: "/cache/"}, // Must match the fullPath exactly
|
||||
{NeverWatchPath: "/logs/"}, // Must match the fullPath exactly
|
||||
ItemRules: []settings.ConditionalRule{
|
||||
{NeverWatchPath: "/temp/"},
|
||||
{NeverWatchPath: "/cache/"},
|
||||
},
|
||||
})
|
||||
|
||||
// Simulate a routine scan (wasIndexed=true, IsRoutineScan=true)
|
||||
idx.wasIndexed = true
|
||||
config := actionConfig{IsRoutineScan: true}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fullPath string
|
||||
baseName string
|
||||
shouldSkip bool
|
||||
description string
|
||||
name string
|
||||
path string
|
||||
hasBeenIndexed bool
|
||||
expectedSkipped bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Skip NeverWatch path during routine scan",
|
||||
fullPath: "/cache/",
|
||||
baseName: "cache",
|
||||
shouldSkip: true,
|
||||
description: "Folder with neverWatch should be skipped in routine scan",
|
||||
name: "Initial scan - don't skip NeverWatch",
|
||||
path: "/temp/",
|
||||
hasBeenIndexed: false,
|
||||
expectedSkipped: false,
|
||||
description: "NeverWatch paths should NOT be skipped during initial scan",
|
||||
},
|
||||
{
|
||||
name: "Skip NeverWatch name during routine scan",
|
||||
fullPath: "/logs/",
|
||||
baseName: "logs",
|
||||
shouldSkip: true,
|
||||
description: "Folder with neverWatch name should be skipped in routine scan",
|
||||
name: "Routine scan - skip NeverWatch",
|
||||
path: "/temp/",
|
||||
hasBeenIndexed: true,
|
||||
expectedSkipped: true,
|
||||
description: "NeverWatch paths SHOULD be skipped after initial scan",
|
||||
},
|
||||
{
|
||||
name: "Don't skip regular folder",
|
||||
fullPath: "/regular/",
|
||||
baseName: "regular",
|
||||
shouldSkip: false,
|
||||
description: "Regular folder should not be skipped",
|
||||
name: "Initial scan - regular path",
|
||||
path: "/regular/",
|
||||
hasBeenIndexed: false,
|
||||
expectedSkipped: false,
|
||||
description: "Regular paths should not be skipped during initial scan",
|
||||
},
|
||||
{
|
||||
name: "Routine scan - regular path",
|
||||
path: "/regular/",
|
||||
hasBeenIndexed: true,
|
||||
expectedSkipped: false,
|
||||
description: "Regular paths should not be skipped during routine scans",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := idx.shouldSkip(true, false, tt.fullPath, tt.baseName, config)
|
||||
if result != tt.shouldSkip {
|
||||
t.Errorf("%s: expected shouldSkip=%v, got %v (fullPath=%s, baseName=%s)",
|
||||
tt.description, tt.shouldSkip, result, tt.fullPath, tt.baseName)
|
||||
// Simulate whether index has been scanned before
|
||||
if tt.hasBeenIndexed {
|
||||
// Set a fake last indexed time to simulate completed scan
|
||||
idx.mu.Lock()
|
||||
if idx.scanners == nil {
|
||||
idx.scanners = make(map[string]*Scanner)
|
||||
}
|
||||
scanner := &Scanner{
|
||||
lastScanned: time.Now(),
|
||||
}
|
||||
idx.scanners["/"] = scanner
|
||||
idx.mu.Unlock()
|
||||
} else {
|
||||
// Clear scanners to simulate initial state
|
||||
idx.mu.Lock()
|
||||
idx.scanners = make(map[string]*Scanner)
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
|
||||
result := idx.IsNeverWatchPath(tt.path)
|
||||
if result != tt.expectedSkipped {
|
||||
t.Errorf("%s: expected IsNeverWatchPath=%v, got %v (path=%s, hasBeenIndexed=%v)",
|
||||
tt.description, tt.expectedSkipped, result, tt.path, tt.hasBeenIndexed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test initial scan (IsRoutineScan=false) - NeverWatch folders should NOT be skipped
|
||||
config.IsRoutineScan = false
|
||||
result := idx.shouldSkip(true, false, "/cache/", "cache", config)
|
||||
if result != false {
|
||||
t.Errorf("NeverWatch folder should NOT be skipped during initial scan, got shouldSkip=%v", result)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
@@ -11,6 +14,15 @@ import (
|
||||
func setupTestIndexForDeletion(t *testing.T) *Index {
|
||||
t.Helper()
|
||||
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_deletion", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create index with mock data
|
||||
idx := &Index{
|
||||
Source: settings.Source{
|
||||
@@ -20,60 +32,112 @@ func setupTestIndexForDeletion(t *testing.T) *Index {
|
||||
DisableIndexing: false,
|
||||
},
|
||||
},
|
||||
Directories: make(map[string]*iteminfo.FileInfo),
|
||||
DirectoriesLedger: make(map[string]struct{}),
|
||||
FoundHardLinks: make(map[string]uint64),
|
||||
processedInodes: make(map[uint64]struct{}),
|
||||
mock: true, // Enable mock mode
|
||||
db: indexDB,
|
||||
mock: true, // Enable mock mode
|
||||
}
|
||||
|
||||
// Create mock directory structure with predictable sizes
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{
|
||||
// Create mock directory structure with predictable sizes using database
|
||||
now := time.Now()
|
||||
|
||||
// Root directory
|
||||
rootDir := &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
Type: "directory",
|
||||
Size: 1000, // Total: 100 + 200 + 700 (subdir)
|
||||
Name: "/",
|
||||
Type: "directory",
|
||||
Size: 1000, // Total: 100 + 200 + 700 (subdir)
|
||||
ModTime: now,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Size: 100}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Size: 200}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Size: 100, ModTime: now}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Size: 200, ModTime: now}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "subdir", Type: "directory", Size: 700},
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test_delete", "/", rootDir)
|
||||
|
||||
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
|
||||
// File1
|
||||
file1 := &iteminfo.FileInfo{
|
||||
Path: "/file1.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file1.txt",
|
||||
Type: "file",
|
||||
Size: 100,
|
||||
ModTime: now,
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test_delete", "/file1.txt", file1)
|
||||
|
||||
// File2
|
||||
file2 := &iteminfo.FileInfo{
|
||||
Path: "/file2.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file2.txt",
|
||||
Type: "file",
|
||||
Size: 200,
|
||||
ModTime: now,
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test_delete", "/file2.txt", file2)
|
||||
|
||||
// Subdir
|
||||
subdir := &iteminfo.FileInfo{
|
||||
Path: "/subdir/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "subdir",
|
||||
Type: "directory",
|
||||
Size: 700, // 300 + 400 (deepdir)
|
||||
Name: "subdir",
|
||||
Type: "directory",
|
||||
Size: 700, // 300 + 400 (deepdir)
|
||||
ModTime: now,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file3.txt", Size: 300}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file3.txt", Size: 300, ModTime: now}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "deepdir", Type: "directory", Size: 400},
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test_delete", "/subdir/", subdir)
|
||||
|
||||
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/",
|
||||
// File3
|
||||
file3 := &iteminfo.FileInfo{
|
||||
Path: "/subdir/file3.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "deepdir",
|
||||
Type: "directory",
|
||||
Size: 400,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file4.txt", Size: 400}},
|
||||
Name: "file3.txt",
|
||||
Type: "file",
|
||||
Size: 300,
|
||||
ModTime: now,
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test_delete", "/subdir/file3.txt", file3)
|
||||
|
||||
idx.DirectoriesLedger["/"] = struct{}{}
|
||||
idx.DirectoriesLedger["/subdir/"] = struct{}{}
|
||||
idx.DirectoriesLedger["/subdir/deepdir/"] = struct{}{}
|
||||
// Deepdir
|
||||
deepdir := &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "deepdir",
|
||||
Type: "directory",
|
||||
Size: 400,
|
||||
ModTime: now,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file4.txt", Size: 400, ModTime: now}},
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test_delete", "/subdir/deepdir/", deepdir)
|
||||
|
||||
// File4
|
||||
file4 := &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/file4.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file4.txt",
|
||||
Type: "file",
|
||||
Size: 400,
|
||||
ModTime: now,
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test_delete", "/subdir/deepdir/file4.txt", file4)
|
||||
|
||||
return idx
|
||||
}
|
||||
@@ -84,18 +148,30 @@ func TestDeleteMetadata_RecursiveDirectoryCleanup(t *testing.T) {
|
||||
// Delete a directory recursively
|
||||
idx.DeleteMetadata("/subdir/deepdir/", true, true)
|
||||
|
||||
// Verify it was removed from Directories
|
||||
if _, exists := idx.Directories["/subdir/deepdir/"]; exists {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed from Directories")
|
||||
// Verify it was removed from database
|
||||
item, err := idx.db.GetItem("test_delete", "/subdir/deepdir/")
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
t.Fatalf("Error checking database: %v", err)
|
||||
}
|
||||
if item != nil {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed from database")
|
||||
}
|
||||
|
||||
// Verify it was removed from DirectoriesLedger
|
||||
if _, exists := idx.DirectoriesLedger["/subdir/deepdir/"]; exists {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed from DirectoriesLedger")
|
||||
// Verify file4 was also removed
|
||||
file4, err := idx.db.GetItem("test_delete", "/subdir/deepdir/file4.txt")
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
t.Fatalf("Error checking database: %v", err)
|
||||
}
|
||||
if file4 != nil {
|
||||
t.Error("File '/subdir/deepdir/file4.txt' should have been removed from database")
|
||||
}
|
||||
|
||||
// Verify parent still exists
|
||||
if _, exists := idx.Directories["/subdir/"]; !exists {
|
||||
parent, err := idx.db.GetItem("test_delete", "/subdir/")
|
||||
if err != nil {
|
||||
t.Fatalf("Error checking database: %v", err)
|
||||
}
|
||||
if parent == nil {
|
||||
t.Error("Parent directory '/subdir/' should still exist")
|
||||
}
|
||||
}
|
||||
@@ -104,33 +180,35 @@ func TestDeleteMetadata_RecursiveWithSubdirectories(t *testing.T) {
|
||||
idx := setupTestIndexForDeletion(t)
|
||||
|
||||
// Add a deeper subdirectory
|
||||
idx.Directories["/subdir/deepdir/verydeep/"] = &iteminfo.FileInfo{
|
||||
verydeep := &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/verydeep/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "verydeep",
|
||||
Type: "directory",
|
||||
Size: 50,
|
||||
Name: "verydeep",
|
||||
Type: "directory",
|
||||
Size: 50,
|
||||
ModTime: time.Now(),
|
||||
},
|
||||
}
|
||||
idx.DirectoriesLedger["/subdir/deepdir/verydeep/"] = struct{}{}
|
||||
_ = idx.db.InsertItem("test_delete", "/subdir/deepdir/verydeep/", verydeep)
|
||||
|
||||
// Delete the parent recursively
|
||||
idx.DeleteMetadata("/subdir/deepdir/", true, true)
|
||||
|
||||
// Verify all subdirectories were removed
|
||||
if _, exists := idx.Directories["/subdir/deepdir/"]; exists {
|
||||
deepdir, err := idx.db.GetItem("test_delete", "/subdir/deepdir/")
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
t.Fatalf("Error checking database: %v", err)
|
||||
}
|
||||
if deepdir != nil {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed")
|
||||
}
|
||||
if _, exists := idx.Directories["/subdir/deepdir/verydeep/"]; exists {
|
||||
t.Error("Subdirectory '/subdir/deepdir/verydeep/' should have been removed")
|
||||
}
|
||||
|
||||
// Verify ledger was cleaned
|
||||
if _, exists := idx.DirectoriesLedger["/subdir/deepdir/"]; exists {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed from ledger")
|
||||
verydeepItem, err := idx.db.GetItem("test_delete", "/subdir/deepdir/verydeep/")
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
t.Fatalf("Error checking database: %v", err)
|
||||
}
|
||||
if _, exists := idx.DirectoriesLedger["/subdir/deepdir/verydeep/"]; exists {
|
||||
t.Error("Subdirectory '/subdir/deepdir/verydeep/' should have been removed from ledger")
|
||||
if verydeepItem != nil {
|
||||
t.Error("Subdirectory '/subdir/deepdir/verydeep/' should have been removed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,16 +218,22 @@ func TestDeleteMetadata_FileRemoval(t *testing.T) {
|
||||
// Delete a file
|
||||
idx.DeleteMetadata("/subdir/file3.txt", false, false)
|
||||
|
||||
// Verify file was removed from parent's Files slice
|
||||
parentInfo, exists := idx.Directories["/subdir/"]
|
||||
if !exists {
|
||||
t.Fatal("Parent directory not found")
|
||||
// Verify file was removed from database
|
||||
file3, err := idx.db.GetItem("test_delete", "/subdir/file3.txt")
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
t.Fatalf("Error checking database: %v", err)
|
||||
}
|
||||
if file3 != nil {
|
||||
t.Error("File '/subdir/file3.txt' should have been removed from database")
|
||||
}
|
||||
|
||||
for _, file := range parentInfo.Files {
|
||||
if file.Name == "file3.txt" {
|
||||
t.Error("File 'file3.txt' should have been removed from parent's Files slice")
|
||||
}
|
||||
// Verify parent directory still exists
|
||||
parent, err := idx.db.GetItem("test_delete", "/subdir/")
|
||||
if err != nil {
|
||||
t.Fatalf("Error checking database: %v", err)
|
||||
}
|
||||
if parent == nil {
|
||||
t.Fatal("Parent directory not found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,21 +243,30 @@ func TestDeleteMetadata_DirectoryFromParentFolder(t *testing.T) {
|
||||
// Delete a directory (non-recursive)
|
||||
idx.DeleteMetadata("/subdir/deepdir/", true, false)
|
||||
|
||||
// Verify directory was removed from Directories
|
||||
if _, exists := idx.Directories["/subdir/deepdir/"]; exists {
|
||||
// Verify directory was removed from database
|
||||
deepdir, err := idx.db.GetItem("test_delete", "/subdir/deepdir/")
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
t.Fatalf("Error checking database: %v", err)
|
||||
}
|
||||
if deepdir != nil {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed")
|
||||
}
|
||||
|
||||
// Verify it was removed from parent's Folders slice
|
||||
parentInfo, exists := idx.Directories["/subdir/"]
|
||||
if !exists {
|
||||
// Verify parent directory still exists
|
||||
parent, err := idx.db.GetItem("test_delete", "/subdir/")
|
||||
if err != nil {
|
||||
t.Fatalf("Error checking database: %v", err)
|
||||
}
|
||||
if parent == nil {
|
||||
t.Fatal("Parent directory not found")
|
||||
}
|
||||
|
||||
for _, folder := range parentInfo.Folders {
|
||||
if folder.Name == "deepdir" {
|
||||
t.Error("Folder 'deepdir' should have been removed from parent's Folders slice")
|
||||
}
|
||||
// Verify child file still exists (non-recursive delete)
|
||||
file4, err := idx.db.GetItem("test_delete", "/subdir/deepdir/file4.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Error checking database: %v", err)
|
||||
}
|
||||
if file4 == nil {
|
||||
t.Error("File '/subdir/deepdir/file4.txt' should still exist (non-recursive delete)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestFetchExtendedAttributes tests the fetchExtendedAttributes helper function
|
||||
func TestFetchExtendedAttributes(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
adjustedPath string
|
||||
combinedPath string
|
||||
files []os.FileInfo
|
||||
opts Options
|
||||
expectHasPreview bool
|
||||
expectMapNotEmpty bool
|
||||
}{
|
||||
{
|
||||
name: "SkipExtendedAttrs returns empty",
|
||||
adjustedPath: "/test/",
|
||||
combinedPath: "/test/",
|
||||
files: []os.FileInfo{},
|
||||
opts: Options{
|
||||
SkipExtendedAttrs: true,
|
||||
Recursive: false,
|
||||
},
|
||||
expectHasPreview: false,
|
||||
expectMapNotEmpty: false,
|
||||
},
|
||||
{
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDirectoryName tests the directory name extraction logic
|
||||
func TestGetDirectoryName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
indexPath string
|
||||
idxPath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Normal directory path",
|
||||
indexPath: "/documents/",
|
||||
idxPath: "/home/user",
|
||||
expected: "documents",
|
||||
},
|
||||
{
|
||||
name: "Root path",
|
||||
indexPath: "/",
|
||||
idxPath: "/home/user",
|
||||
expected: "user",
|
||||
},
|
||||
{
|
||||
name: "Path with trailing slash",
|
||||
indexPath: "/mydir/",
|
||||
idxPath: "/base",
|
||||
expected: "mydir",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Note: The actual getDirectoryName logic is now inline in GetDirInfoCore
|
||||
// This test verifies the expected behavior using filepath.Base
|
||||
|
||||
baseName := filepath.Base(strings.TrimSuffix(tt.indexPath, "/"))
|
||||
if tt.indexPath == "/" {
|
||||
baseName = filepath.Base(tt.idxPath)
|
||||
}
|
||||
|
||||
if baseName != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, baseName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessDirectoryItem tests the processDirectoryItem helper function
|
||||
func TestProcessDirectoryItem(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
// Set up folder size in memory
|
||||
idx.SetFolderSize("/test/subdir/", 500)
|
||||
|
||||
now := time.Now()
|
||||
mockDir := &mockFileInfo{
|
||||
name: "subdir",
|
||||
size: 0,
|
||||
mode: os.ModeDir,
|
||||
modTime: now,
|
||||
isDir: true,
|
||||
}
|
||||
|
||||
mockDirNoSize := &mockFileInfo{
|
||||
name: "nosize",
|
||||
size: 0,
|
||||
mode: os.ModeDir,
|
||||
modTime: now,
|
||||
isDir: true,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
file os.FileInfo
|
||||
combinedPath string
|
||||
realPath string
|
||||
fullCombined string
|
||||
subdirHasPreviewMap map[string]bool
|
||||
opts Options
|
||||
expectNil bool
|
||||
expectSize int64
|
||||
expectShouldCount bool
|
||||
}{
|
||||
{
|
||||
name: "Non-recursive with cached size",
|
||||
file: mockDir,
|
||||
combinedPath: "/test/",
|
||||
realPath: "/real/test",
|
||||
fullCombined: "/test/subdir",
|
||||
subdirHasPreviewMap: map[string]bool{
|
||||
"/test/subdir/": true,
|
||||
},
|
||||
opts: Options{
|
||||
Recursive: false,
|
||||
SkipExtendedAttrs: false,
|
||||
},
|
||||
expectNil: false,
|
||||
expectSize: 500,
|
||||
expectShouldCount: true,
|
||||
},
|
||||
{
|
||||
name: "Non-recursive without cached size",
|
||||
file: mockDirNoSize,
|
||||
combinedPath: "/test/",
|
||||
realPath: "/real/test",
|
||||
fullCombined: "/test/nosize",
|
||||
subdirHasPreviewMap: map[string]bool{
|
||||
"/test/nosize/": false,
|
||||
},
|
||||
opts: Options{
|
||||
Recursive: false,
|
||||
SkipExtendedAttrs: false,
|
||||
},
|
||||
expectNil: false,
|
||||
expectSize: 0,
|
||||
expectShouldCount: true,
|
||||
},
|
||||
{
|
||||
name: "SkipExtendedAttrs",
|
||||
file: mockDir,
|
||||
combinedPath: "/test/",
|
||||
realPath: "/real/test",
|
||||
fullCombined: "/test/subdir",
|
||||
subdirHasPreviewMap: map[string]bool{
|
||||
"/test/subdir/": true,
|
||||
},
|
||||
opts: Options{
|
||||
Recursive: false,
|
||||
SkipExtendedAttrs: true,
|
||||
},
|
||||
expectNil: false,
|
||||
expectSize: 500,
|
||||
expectShouldCount: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
itemInfo, size, shouldCount := idx.processDirectoryItem(
|
||||
tt.file, tt.combinedPath,
|
||||
tt.subdirHasPreviewMap, tt.opts, nil,
|
||||
)
|
||||
|
||||
if tt.expectNil {
|
||||
if itemInfo != nil {
|
||||
t.Errorf("expected nil itemInfo, got %v", itemInfo)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if itemInfo == nil {
|
||||
t.Fatalf("expected non-nil itemInfo")
|
||||
}
|
||||
|
||||
if size != tt.expectSize {
|
||||
t.Errorf("expected size=%d, got %d", tt.expectSize, size)
|
||||
}
|
||||
|
||||
if shouldCount != tt.expectShouldCount {
|
||||
t.Errorf("expected shouldCount=%v, got %v", tt.expectShouldCount, shouldCount)
|
||||
}
|
||||
|
||||
if itemInfo.Type != "directory" {
|
||||
t.Errorf("expected type=directory, got %s", itemInfo.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessFileItem tests the processFileItem helper function
|
||||
func TestProcessFileItem(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
now := time.Now()
|
||||
mockFile := &mockFileInfo{
|
||||
name: "testfile.txt",
|
||||
size: 1024,
|
||||
mode: 0,
|
||||
modTime: now,
|
||||
isDir: false,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
file os.FileInfo
|
||||
realPath string
|
||||
combinedPath string
|
||||
fullCombined string
|
||||
opts Options
|
||||
scanner *Scanner
|
||||
expectSize int64
|
||||
expectShouldCount bool
|
||||
}{
|
||||
{
|
||||
name: "API call (non-recursive, no scanner)",
|
||||
file: mockFile,
|
||||
realPath: "/real/test",
|
||||
combinedPath: "/test/",
|
||||
fullCombined: "/test/testfile.txt",
|
||||
opts: Options{
|
||||
Recursive: false,
|
||||
SkipExtendedAttrs: false,
|
||||
},
|
||||
scanner: nil,
|
||||
expectSize: 1024,
|
||||
expectShouldCount: true,
|
||||
},
|
||||
{
|
||||
name: "SkipExtendedAttrs",
|
||||
file: mockFile,
|
||||
realPath: "/real/test",
|
||||
combinedPath: "/test/",
|
||||
fullCombined: "/test/testfile.txt",
|
||||
opts: Options{
|
||||
Recursive: false,
|
||||
SkipExtendedAttrs: true,
|
||||
},
|
||||
scanner: nil,
|
||||
expectSize: 1024,
|
||||
expectShouldCount: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
itemInfo, size, shouldCount, bubblesUp := idx.processFileItem(
|
||||
tt.file, tt.combinedPath, tt.opts, tt.scanner,
|
||||
)
|
||||
|
||||
if itemInfo == nil {
|
||||
t.Fatalf("expected non-nil itemInfo")
|
||||
}
|
||||
|
||||
if size != tt.expectSize {
|
||||
t.Errorf("expected size=%d, got %d", tt.expectSize, size)
|
||||
}
|
||||
|
||||
if shouldCount != tt.expectShouldCount {
|
||||
t.Errorf("expected shouldCount=%v, got %v", tt.expectShouldCount, shouldCount)
|
||||
}
|
||||
|
||||
// bubblesUp is a boolean, just verify it's set
|
||||
_ = bubblesUp
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mockFileInfo is a simple mock implementation of os.FileInfo for testing
|
||||
type mockFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
mode os.FileMode
|
||||
modTime time.Time
|
||||
isDir bool
|
||||
}
|
||||
|
||||
func (m *mockFileInfo) Name() string { return m.name }
|
||||
func (m *mockFileInfo) Size() int64 { return m.size }
|
||||
func (m *mockFileInfo) Mode() os.FileMode { return m.mode }
|
||||
func (m *mockFileInfo) ModTime() time.Time { return m.modTime }
|
||||
func (m *mockFileInfo) IsDir() bool { return m.isDir }
|
||||
func (m *mockFileInfo) Sys() interface{} { return nil }
|
||||
+1326
-372
File diff suppressed because it is too large.
Load diff
@@ -2,8 +2,10 @@ package indexing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
@@ -11,67 +13,135 @@ import (
|
||||
func setupTestIndex(t *testing.T) (*Index, string, func()) {
|
||||
t.Helper()
|
||||
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_indexing", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize index with mock data
|
||||
idx := &Index{
|
||||
ReducedIndex: ReducedIndex{},
|
||||
Source: settings.Source{
|
||||
Name: "test",
|
||||
Path: "/mock/path",
|
||||
Config: settings.SourceConfig{
|
||||
DisableIndexing: false,
|
||||
UseLogicalSize: true,
|
||||
},
|
||||
},
|
||||
wasIndexed: true,
|
||||
mock: true, // Enable mock mode
|
||||
Directories: make(map[string]*iteminfo.FileInfo),
|
||||
DirectoriesLedger: make(map[string]struct{}),
|
||||
FoundHardLinks: make(map[string]uint64),
|
||||
processedInodes: make(map[uint64]struct{}),
|
||||
mock: true, // Enable mock mode
|
||||
db: indexDB,
|
||||
scanUpdatedPaths: make(map[string]bool),
|
||||
folderSizes: make(map[string]uint64), // Initialize folder sizes map
|
||||
folderSizesUnsynced: make(map[string]struct{}), // Initialize unsynced map
|
||||
}
|
||||
|
||||
// Create mock directory structure with predictable sizes
|
||||
// Using logical file sizes instead of disk allocation sizes
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{
|
||||
// Create mock directory structure with predictable sizes using database
|
||||
now := time.Now()
|
||||
|
||||
// Root directory
|
||||
rootDir := &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
Type: "directory",
|
||||
Size: 1000, // Total logical size: 100 + 200 + 300 + 400
|
||||
Name: "/",
|
||||
Type: "directory",
|
||||
Size: 1000, // Total logical size: 100 + 200 + 300 + 400
|
||||
ModTime: now,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Size: 100}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Size: 200}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Size: 100, ModTime: now}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Size: 200, ModTime: now}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "subdir", Type: "directory", Size: 700}, // 300 + 400
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test", "/", rootDir)
|
||||
|
||||
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
|
||||
// File1
|
||||
file1 := &iteminfo.FileInfo{
|
||||
Path: "/file1.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file1.txt",
|
||||
Type: "file",
|
||||
Size: 100,
|
||||
ModTime: now,
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test", "/file1.txt", file1)
|
||||
|
||||
// File2
|
||||
file2 := &iteminfo.FileInfo{
|
||||
Path: "/file2.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file2.txt",
|
||||
Type: "file",
|
||||
Size: 200,
|
||||
ModTime: now,
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test", "/file2.txt", file2)
|
||||
|
||||
// Subdir
|
||||
subdir := &iteminfo.FileInfo{
|
||||
Path: "/subdir/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "subdir",
|
||||
Type: "directory",
|
||||
Size: 700, // 300 + 400
|
||||
Name: "subdir",
|
||||
Type: "directory",
|
||||
Size: 700, // 300 + 400
|
||||
ModTime: now,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file3.txt", Size: 300}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file3.txt", Size: 300, ModTime: now}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "deepdir", Type: "directory", Size: 400},
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test", "/subdir/", subdir)
|
||||
|
||||
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/",
|
||||
// File3
|
||||
file3 := &iteminfo.FileInfo{
|
||||
Path: "/subdir/file3.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "deepdir",
|
||||
Type: "directory",
|
||||
Size: 400,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file4.txt", Size: 400}},
|
||||
Name: "file3.txt",
|
||||
Type: "file",
|
||||
Size: 300,
|
||||
ModTime: now,
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test", "/subdir/file3.txt", file3)
|
||||
|
||||
// Deepdir
|
||||
deepdir := &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "deepdir",
|
||||
Type: "directory",
|
||||
Size: 400,
|
||||
ModTime: now,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file4.txt", Size: 400, ModTime: now}},
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test", "/subdir/deepdir/", deepdir)
|
||||
|
||||
// File4
|
||||
file4 := &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/file4.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file4.txt",
|
||||
Type: "file",
|
||||
Size: 400,
|
||||
ModTime: now,
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test", "/subdir/deepdir/file4.txt", file4)
|
||||
|
||||
// Cleanup function (no-op since we're not using filesystem)
|
||||
cleanup := func() {
|
||||
@@ -114,7 +184,7 @@ func TestFolderSizeCalculation(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Get directory info directly from mock data
|
||||
dirInfo, exists := idx.GetMetadataInfo(tt.path, true)
|
||||
dirInfo, exists := idx.GetMetadataInfo(tt.path, true, false)
|
||||
if !exists {
|
||||
t.Fatalf("Directory %s not found in mock data", tt.path)
|
||||
}
|
||||
@@ -126,102 +196,35 @@ func TestFolderSizeCalculation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecursiveSizeUpdate(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
// Verify initial sizes (using logical sizes, not disk allocation)
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
if rootInfo.Size != 1000 {
|
||||
t.Errorf("Initial root size: got %d, want 1000", rootInfo.Size)
|
||||
}
|
||||
|
||||
subdirInfo, exists := idx.GetMetadataInfo("/subdir/", true)
|
||||
if !exists {
|
||||
t.Fatal("Subdir metadata not found")
|
||||
}
|
||||
initialSubdirSize := subdirInfo.Size
|
||||
if initialSubdirSize != 700 {
|
||||
t.Errorf("Initial subdir size: got %d, want 700", initialSubdirSize)
|
||||
}
|
||||
|
||||
// Simulate adding a new file to deepdir by updating the mock data
|
||||
// Add file5.txt (500 bytes) to deepdir
|
||||
deepdirInfo := idx.Directories["/subdir/deepdir/"]
|
||||
deepdirInfo.Files = append(deepdirInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file5.txt",
|
||||
Size: 500,
|
||||
},
|
||||
})
|
||||
|
||||
// Update deepdir size
|
||||
oldDeepdirSize := deepdirInfo.Size
|
||||
deepdirInfo.Size = 900 // 400 + 500
|
||||
|
||||
// Simulate the recursive size update by calling the method directly
|
||||
idx.RecursiveUpdateDirSizes(deepdirInfo, oldDeepdirSize)
|
||||
|
||||
// Check that deepdir size updated
|
||||
deepdirInfo, exists = idx.GetMetadataInfo("/subdir/deepdir/", true)
|
||||
if !exists {
|
||||
t.Fatal("Deepdir metadata not found after update")
|
||||
}
|
||||
expectedDeepdirSize := int64(900) // 400 + 500
|
||||
if deepdirInfo.Size != expectedDeepdirSize {
|
||||
t.Errorf("Deepdir size after adding file: got %d, want %d", deepdirInfo.Size, expectedDeepdirSize)
|
||||
}
|
||||
|
||||
// Check that subdir size updated (includes the new file)
|
||||
subdirInfo, exists = idx.GetMetadataInfo("/subdir/", true)
|
||||
if !exists {
|
||||
t.Fatal("Subdir metadata not found after propagation")
|
||||
}
|
||||
|
||||
// Subdir should now contain file3.txt + deepdir's new size
|
||||
expectedSubdirSize := int64(1200) // 300 + 900
|
||||
if subdirInfo.Size != expectedSubdirSize {
|
||||
t.Errorf("Subdir size after propagation: got %d, want %d", subdirInfo.Size, expectedSubdirSize)
|
||||
}
|
||||
|
||||
// Check that root size updated (includes changes propagated from subdir)
|
||||
rootInfo, exists = idx.GetMetadataInfo("/", true)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found after propagation")
|
||||
}
|
||||
expectedRootSize := int64(1500) // 100 + 200 + 1200
|
||||
if rootInfo.Size != expectedRootSize {
|
||||
t.Errorf("Root size after propagation: got %d, want %d", rootInfo.Size, expectedRootSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonRecursiveMetadataUpdate(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
// Get initial root size
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true)
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
initialRootSize := rootInfo.Size
|
||||
|
||||
// Simulate adding a new file directly to root by updating mock data
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
// Simulate adding a new file directly to root by updating the database
|
||||
file3 := &iteminfo.FileInfo{
|
||||
Path: "/file3.txt",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file3.txt",
|
||||
Size: 150,
|
||||
Name: "file3.txt",
|
||||
Type: "file",
|
||||
Size: 150,
|
||||
ModTime: time.Now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
_ = idx.db.InsertItem("test", "/file3.txt", file3)
|
||||
|
||||
// Update root size (non-recursive - only direct files)
|
||||
rootInfo.Size = initialRootSize + 150
|
||||
_ = idx.db.InsertItem("test", "/", rootInfo)
|
||||
|
||||
// Check that root size updated
|
||||
rootInfo, exists = idx.GetMetadataInfo("/", true)
|
||||
rootInfo, exists = idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found after non-recursive update")
|
||||
}
|
||||
@@ -232,137 +235,47 @@ func TestNonRecursiveMetadataUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecursiveUpdateDirSizes(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
// Setup initial metadata manually
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 1000,
|
||||
},
|
||||
}
|
||||
|
||||
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
|
||||
Path: "/subdir/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 700,
|
||||
},
|
||||
}
|
||||
|
||||
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 400,
|
||||
},
|
||||
}
|
||||
|
||||
// Simulate updating deepdir from 400 to 900 (added 500 bytes)
|
||||
deepdirInfo := idx.Directories["/subdir/deepdir/"]
|
||||
previousSize := deepdirInfo.Size
|
||||
deepdirInfo.Size = 900
|
||||
|
||||
// Call recursiveUpdateDirSizes
|
||||
idx.RecursiveUpdateDirSizes(deepdirInfo, previousSize)
|
||||
|
||||
// Check that subdir size updated
|
||||
subdirInfo := idx.Directories["/subdir/"]
|
||||
expectedSubdirSize := int64(1200) // 700 + 500
|
||||
if subdirInfo.Size != expectedSubdirSize {
|
||||
t.Errorf("Subdir size after recursive update: got %d, want %d", subdirInfo.Size, expectedSubdirSize)
|
||||
}
|
||||
|
||||
// Check that root size updated
|
||||
rootInfo := idx.Directories["/"]
|
||||
expectedRootSize := int64(1500) // 1000 + 500
|
||||
if rootInfo.Size != expectedRootSize {
|
||||
t.Errorf("Root size after recursive update: got %d, want %d", rootInfo.Size, expectedRootSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeDecreasePropagate(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
// Setup initial metadata manually
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 1000,
|
||||
},
|
||||
}
|
||||
|
||||
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
|
||||
Path: "/subdir/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 700,
|
||||
},
|
||||
}
|
||||
|
||||
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 400,
|
||||
},
|
||||
}
|
||||
|
||||
// Simulate updating deepdir from 400 to 100 (removed 300 bytes)
|
||||
deepdirInfo := idx.Directories["/subdir/deepdir/"]
|
||||
previousSize := deepdirInfo.Size
|
||||
deepdirInfo.Size = 100
|
||||
|
||||
// Call recursiveUpdateDirSizes
|
||||
idx.RecursiveUpdateDirSizes(deepdirInfo, previousSize)
|
||||
|
||||
// Check that subdir size decreased
|
||||
subdirInfo := idx.Directories["/subdir/"]
|
||||
expectedSubdirSize := int64(400) // 700 - 300
|
||||
if subdirInfo.Size != expectedSubdirSize {
|
||||
t.Errorf("Subdir size after decrease: got %d, want %d", subdirInfo.Size, expectedSubdirSize)
|
||||
}
|
||||
|
||||
// Check that root size decreased
|
||||
rootInfo := idx.Directories["/"]
|
||||
expectedRootSize := int64(700) // 1000 - 300
|
||||
if rootInfo.Size != expectedRootSize {
|
||||
t.Errorf("Root size after decrease: got %d, want %d", rootInfo.Size, expectedRootSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewDoesNotPropagateFromSubdirectories(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
// Add an image file to subdir in mock data (should have preview)
|
||||
subdirInfo := idx.Directories["/subdir/"]
|
||||
subdirInfo.Files = append(subdirInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "image.jpg",
|
||||
Size: 100,
|
||||
HasPreview: true, // Image files have preview
|
||||
},
|
||||
})
|
||||
subdirInfo.HasPreview = true // Subdir now has preview
|
||||
|
||||
// Check that subdir has preview (due to image.jpg)
|
||||
subdirInfo, exists := idx.GetMetadataInfo("/subdir/", true)
|
||||
// Add an image file to subdir in database (should have preview)
|
||||
subdirInfo, exists := idx.GetMetadataInfo("/subdir/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Subdir metadata not found")
|
||||
}
|
||||
if !subdirInfo.HasPreview {
|
||||
imageFile := &iteminfo.FileInfo{
|
||||
Path: "/subdir/image.jpg",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "image.jpg",
|
||||
Type: "file",
|
||||
Size: 100,
|
||||
HasPreview: true, // Image files have preview
|
||||
ModTime: time.Now(),
|
||||
},
|
||||
}
|
||||
_ = idx.db.InsertItem("test", "/subdir/image.jpg", imageFile)
|
||||
subdirInfo.HasPreview = true // Subdir now has preview
|
||||
_ = idx.db.InsertItem("test", "/subdir/", subdirInfo)
|
||||
|
||||
// Check that subdir has preview (due to image.jpg)
|
||||
subdirInfoCheck, exists := idx.GetMetadataInfo("/subdir/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Subdir metadata not found")
|
||||
}
|
||||
if !subdirInfoCheck.HasPreview {
|
||||
t.Error("Subdir should have HasPreview=true due to image file")
|
||||
}
|
||||
|
||||
// Check that root does NOT have preview propagated from subdir
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true)
|
||||
rootInfoCheck, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
|
||||
// Root should only have HasPreview if it has direct previewable files, not from subdirectories
|
||||
// Since root only has text files, it should NOT have preview
|
||||
if rootInfo.HasPreview {
|
||||
if rootInfoCheck.HasPreview {
|
||||
t.Error("Root should NOT have HasPreview=true from subdirectory preview")
|
||||
}
|
||||
}
|
||||
@@ -371,24 +284,32 @@ func TestPreviewPropagatesFromFiles(t *testing.T) {
|
||||
idx, _, cleanup := setupTestIndex(t)
|
||||
defer cleanup()
|
||||
|
||||
// Add an image file to root in mock data (should propagate preview to root)
|
||||
rootInfo := idx.Directories["/"]
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
// Add an image file to root in database (should propagate preview to root)
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
imageFile := &iteminfo.FileInfo{
|
||||
Path: "/image.png",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "image.png",
|
||||
Type: "file",
|
||||
Size: 100,
|
||||
HasPreview: true, // Image files have preview
|
||||
ModTime: time.Now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
_ = idx.db.InsertItem("test", "/image.png", imageFile)
|
||||
rootInfo.HasPreview = true // Root now has preview
|
||||
_ = idx.db.InsertItem("test", "/", rootInfo)
|
||||
|
||||
// Check that root HAS preview (due to direct image.png file)
|
||||
rootInfo, exists := idx.GetMetadataInfo("/", true)
|
||||
rootInfoCheck, exists := idx.GetMetadataInfo("/", true, false)
|
||||
if !exists {
|
||||
t.Fatal("Root metadata not found")
|
||||
}
|
||||
|
||||
if !rootInfo.HasPreview {
|
||||
if !rootInfoCheck.HasPreview {
|
||||
t.Error("Root should have HasPreview=true due to direct image file")
|
||||
}
|
||||
}
|
||||
+436
-147
@@ -2,47 +2,47 @@ package indexing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Each scanner has its own schedule and stats, but only one can run at a time
|
||||
type Scanner struct {
|
||||
// Identity
|
||||
scanPath string // "/" for root scanner, "/Documents/" for child scanners
|
||||
scanPath string
|
||||
|
||||
currentSchedule int
|
||||
smartModifier time.Duration
|
||||
complexity uint // 0-10 scale: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex
|
||||
fullScanCounter int // every 5th scan is a full scan
|
||||
complexity uint
|
||||
fullScanCounter int
|
||||
nextSleepTime time.Duration
|
||||
filesChanged bool
|
||||
lastScanned time.Time
|
||||
quickScanTime int
|
||||
fullScanTime int
|
||||
scanStartTime int64
|
||||
isScanning bool // True when scanner is actively scanning or waiting for mutex
|
||||
|
||||
filesChanged bool
|
||||
lastScanned time.Time
|
||||
quickScanTime int
|
||||
fullScanTime int
|
||||
numDirs uint64
|
||||
numFiles uint64
|
||||
|
||||
// size tracking
|
||||
numDirs uint64 // Local count for this path
|
||||
numFiles uint64 // Local count for this path
|
||||
size uint64 // Size contributed by this scanner (for delta calculation)
|
||||
previousNumDirs uint64 // Previous numDirs value (preserved across scans)
|
||||
previousNumFiles uint64 // Previous numFiles value (preserved across scans)
|
||||
previousSize uint64 // Previous size value (preserved across scans)
|
||||
// Per-scanner state (not shared with other scanners)
|
||||
processedInodes map[uint64]struct{} // Track inodes to detect hardlinks
|
||||
foundHardLinks map[string]uint64 // Path -> size for hardlinks found
|
||||
batchItems []*iteminfo.FileInfo // Accumulates items for bulk insert
|
||||
|
||||
// Reference back to parent index
|
||||
idx *Index
|
||||
|
||||
// Control
|
||||
stopChan chan struct{}
|
||||
stopOnce sync.Once // Ensures stop() is only executed once
|
||||
stopOnce sync.Once // Ensures stopChan is only closed once
|
||||
}
|
||||
|
||||
// start begins the scanner's main loop
|
||||
func (s *Scanner) start() {
|
||||
// Do initial scan for all scanners
|
||||
// Wait a bit to stagger child scanner initial scans (root goes first)
|
||||
if s.scanPath != "/" {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
@@ -57,12 +57,15 @@ func (s *Scanner) start() {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate sleep based on this scanner's schedule
|
||||
sleepTime := s.calculateSleepTime()
|
||||
// Use the sleep time calculated by updateSchedule() (based on OLD schedule before increment)
|
||||
sleepTime := s.nextSleepTime
|
||||
if sleepTime == 0 {
|
||||
// Fallback for first run or if not set
|
||||
sleepTime = s.calculateSleepTime()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
logger.Debugf("Scanner [%s] received stop signal", s.scanPath)
|
||||
return
|
||||
|
||||
case <-time.After(sleepTime):
|
||||
@@ -73,13 +76,32 @@ func (s *Scanner) start() {
|
||||
|
||||
// tryAcquireAndScan attempts to acquire the global scan mutex and run a scan
|
||||
func (s *Scanner) tryAcquireAndScan() {
|
||||
s.idx.scanMutex.Lock()
|
||||
// Ensure isScanning is cleared even if we panic
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Errorf("[%s] Scanner panic recovered: %v", s.scanPath, r)
|
||||
}
|
||||
s.idx.mu.Lock()
|
||||
s.isScanning = false
|
||||
s.idx.mu.Unlock()
|
||||
err := s.idx.PostScan()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to post scan: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Mark which scanner is active (for status/logging)
|
||||
// Set isScanning flag for all scanners
|
||||
s.idx.mu.Lock()
|
||||
s.idx.activeScannerPath = s.scanPath
|
||||
s.isScanning = true
|
||||
s.idx.mu.Unlock()
|
||||
|
||||
// Root scanner can run independently since it's non-recursive
|
||||
// Child scanners must acquire mutex to prevent concurrent child scans
|
||||
if s.scanPath != "/" {
|
||||
s.idx.childScanMutex.Lock()
|
||||
defer s.idx.childScanMutex.Unlock()
|
||||
}
|
||||
|
||||
quick := s.fullScanCounter > 0 && s.fullScanCounter < 5
|
||||
s.fullScanCounter++
|
||||
if s.fullScanCounter >= 5 {
|
||||
@@ -87,26 +109,7 @@ func (s *Scanner) tryAcquireAndScan() {
|
||||
}
|
||||
|
||||
s.runIndexing(quick)
|
||||
|
||||
// Update this scanner's schedule based on results
|
||||
s.updateSchedule()
|
||||
|
||||
// If this is the root scanner, update the last root scan time
|
||||
if s.scanPath == "/" {
|
||||
s.idx.mu.Lock()
|
||||
s.idx.lastRootScanTime = time.Now()
|
||||
s.idx.mu.Unlock()
|
||||
}
|
||||
|
||||
// Clear active scanner
|
||||
s.idx.mu.Lock()
|
||||
s.idx.activeScannerPath = ""
|
||||
s.idx.mu.Unlock()
|
||||
|
||||
s.idx.scanMutex.Unlock()
|
||||
|
||||
// Aggregate stats to Index level and update status
|
||||
s.idx.aggregateStatsFromScanners()
|
||||
}
|
||||
|
||||
// runIndexing performs the actual indexing work
|
||||
@@ -116,126 +119,240 @@ func (s *Scanner) runIndexing(quick bool) {
|
||||
} else {
|
||||
s.runChildScan(quick)
|
||||
}
|
||||
|
||||
s.lastScanned = time.Now()
|
||||
}
|
||||
|
||||
// runRootScan scans only the root directory (non-recursive) and checks for new child directories
|
||||
func (s *Scanner) runRootScan(quick bool) {
|
||||
config := actionConfig{
|
||||
Quick: quick,
|
||||
startTime := time.Now()
|
||||
|
||||
if quick {
|
||||
s.runQuickScanRoot()
|
||||
s.quickScanTime = int(time.Since(startTime).Seconds())
|
||||
} else {
|
||||
s.runFullScanRoot()
|
||||
s.fullScanTime = int(time.Since(startTime).Seconds())
|
||||
s.updateComplexity()
|
||||
}
|
||||
|
||||
s.lastScanned = time.Now()
|
||||
s.checkForNewChildDirectories()
|
||||
if err := s.idx.db.ShrinkMemory(); err != nil {
|
||||
logger.Errorf("Failed to shrink memory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scanner) runFullScanRoot() {
|
||||
config := Options{
|
||||
Recursive: false,
|
||||
IsRoutineScan: true,
|
||||
}
|
||||
|
||||
// Store previous values before scanning (preserved across scans)
|
||||
prevNumDirs := s.previousNumDirs
|
||||
prevNumFiles := s.previousNumFiles
|
||||
prevSize := s.previousSize
|
||||
|
||||
// Reset counters for full scan (they will be incremented during indexing)
|
||||
if !quick {
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
// Reset counters and initialize state for full scan
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.idx.mu.Lock()
|
||||
if s.idx.scanSessionStartTime == 0 {
|
||||
s.idx.scanSessionStartTime = s.scanStartTime
|
||||
}
|
||||
s.idx.mu.Unlock()
|
||||
|
||||
s.processedInodes = make(map[uint64]struct{})
|
||||
s.foundHardLinks = make(map[string]uint64)
|
||||
|
||||
batchSize := s.idx.db.BatchSize
|
||||
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
|
||||
s.filesChanged = false
|
||||
startTime := time.Now()
|
||||
|
||||
err := s.idx.indexDirectory("/", config)
|
||||
_, _, err := s.idx.indexDirectory("/", config, s)
|
||||
if err != nil {
|
||||
logger.Errorf("Root scanner error: %v", err)
|
||||
}
|
||||
|
||||
// Root scanner gets values directly from index metadata - simple!
|
||||
s.idx.mu.RLock()
|
||||
rootDirInfo, exists := s.idx.Directories["/"]
|
||||
s.idx.mu.RUnlock()
|
||||
s.flushBatch()
|
||||
s.purgeStaleEntries(true, false) // isRoot=true, isQuickScan=false
|
||||
s.syncStatsWithDB()
|
||||
|
||||
newNumDirs := prevNumDirs
|
||||
newNumFiles := prevNumFiles
|
||||
newsize := prevSize
|
||||
if exists && rootDirInfo != nil {
|
||||
for _, file := range rootDirInfo.Files {
|
||||
newsize += uint64(file.Size)
|
||||
newNumFiles++
|
||||
}
|
||||
for range rootDirInfo.Folders {
|
||||
newNumDirs++
|
||||
}
|
||||
if s.processedInodes != nil {
|
||||
s.processedInodes = nil
|
||||
s.foundHardLinks = nil
|
||||
}
|
||||
// Update scanner with new values
|
||||
s.size = newsize
|
||||
|
||||
// Update previous values for next scan (preserve history - don't reset on new scans)
|
||||
s.previousNumDirs = newNumDirs
|
||||
s.previousNumFiles = newNumFiles
|
||||
s.previousSize = newsize
|
||||
scanDuration := int(time.Since(startTime).Seconds())
|
||||
if quick {
|
||||
s.quickScanTime = scanDuration
|
||||
} else {
|
||||
s.fullScanTime = scanDuration
|
||||
s.updateComplexity()
|
||||
}
|
||||
s.checkForNewChildDirectories()
|
||||
s.idx.mu.Lock()
|
||||
s.idx.scanUpdatedPaths = make(map[string]bool)
|
||||
s.idx.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scanner) runQuickScanRoot() {
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.filesChanged = false
|
||||
|
||||
// Get root-level folders from folderSizes map
|
||||
s.idx.folderSizesMu.RLock()
|
||||
var foldersToCheck []string
|
||||
for path := range s.idx.folderSizes {
|
||||
// Root scanner: only check direct children (one slash after root, like "/subdir/")
|
||||
if strings.Count(path, "/") == 2 && path != "/" {
|
||||
foldersToCheck = append(foldersToCheck, path)
|
||||
}
|
||||
}
|
||||
s.idx.folderSizesMu.RUnlock()
|
||||
|
||||
batchSize := s.idx.db.BatchSize
|
||||
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
|
||||
|
||||
// Track stats
|
||||
unchangedCount := 0
|
||||
changedCount := 0
|
||||
errorCount := 0
|
||||
|
||||
// Check each folder's modtime
|
||||
for _, folderPath := range foldersToCheck {
|
||||
changed, err := s.checkFolderModtime(folderPath)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
} else if changed {
|
||||
changedCount++
|
||||
} else {
|
||||
unchangedCount++
|
||||
}
|
||||
}
|
||||
|
||||
s.flushBatch()
|
||||
s.purgeStaleEntries(true, true) // isRoot=true, isQuickScan=true
|
||||
s.syncStatsWithDB()
|
||||
}
|
||||
|
||||
// runChildScan scans a specific directory recursively
|
||||
func (s *Scanner) runChildScan(quick bool) {
|
||||
config := actionConfig{
|
||||
Quick: quick,
|
||||
startTime := time.Now()
|
||||
|
||||
if quick {
|
||||
// Quick scan: iterate folderSizes, check modtimes only (257x faster!)
|
||||
s.runQuickScanChild()
|
||||
s.quickScanTime = int(time.Since(startTime).Seconds())
|
||||
} else {
|
||||
// Full scan: walk filesystem recursively, read all directory contents
|
||||
s.runFullScanChild()
|
||||
s.fullScanTime = int(time.Since(startTime).Seconds())
|
||||
s.updateComplexity()
|
||||
}
|
||||
|
||||
s.lastScanned = time.Now()
|
||||
}
|
||||
|
||||
func (s *Scanner) runFullScanChild() {
|
||||
config := Options{
|
||||
Recursive: true,
|
||||
IsRoutineScan: true,
|
||||
}
|
||||
|
||||
// Store previous values before scanning (preserved across scans)
|
||||
prevSize := s.previousSize
|
||||
|
||||
// Reset counters for full scan (they will be incremented during indexing)
|
||||
if !quick {
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
// Reset counters and initialize state for full scan
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.idx.mu.Lock()
|
||||
if s.idx.scanSessionStartTime == 0 {
|
||||
s.idx.scanSessionStartTime = s.scanStartTime
|
||||
}
|
||||
s.idx.mu.Unlock()
|
||||
|
||||
s.processedInodes = make(map[uint64]struct{})
|
||||
s.foundHardLinks = make(map[string]uint64)
|
||||
s.filesChanged = false
|
||||
startTime := time.Now()
|
||||
|
||||
err := s.idx.indexDirectory(s.scanPath, config)
|
||||
batchSize := s.idx.db.BatchSize
|
||||
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
|
||||
|
||||
_, _, err := s.idx.indexDirectory(s.scanPath, config, s)
|
||||
if err != nil {
|
||||
logger.Errorf("Scanner [%s] error: %v", s.scanPath, err)
|
||||
} else if config.Recursive && config.IsRoutineScan {
|
||||
s.idx.mu.Lock()
|
||||
s.idx.incrementScannerDirsUnlocked()
|
||||
s.idx.mu.Unlock()
|
||||
}
|
||||
|
||||
// Calculate new values after scan
|
||||
newNumDirs := s.numDirs
|
||||
newNumFiles := s.numFiles
|
||||
s.flushBatch()
|
||||
s.purgeStaleEntries(false, false) // isRoot=false, isQuickScan=false
|
||||
s.syncStatsWithDB()
|
||||
|
||||
// For child scanners, calculate size from the directory info
|
||||
// Since child scanners don't modify totalSize, we get it from the directory metadata
|
||||
s.idx.mu.RLock()
|
||||
dirInfo, exists := s.idx.Directories[s.scanPath]
|
||||
s.idx.mu.RUnlock()
|
||||
|
||||
newsize := prevSize
|
||||
if exists && dirInfo != nil {
|
||||
newsize = uint64(dirInfo.Size)
|
||||
if s.scanPath != "/" {
|
||||
if err := s.idx.SyncFolderSizesToDB(); err != nil {
|
||||
logger.Errorf("[%s] Failed to sync folder sizes: %v", s.scanPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update scanner with new values
|
||||
s.size = newsize
|
||||
|
||||
// Update previous values for next scan (preserve history - don't reset on new scans)
|
||||
s.previousNumDirs = newNumDirs
|
||||
s.previousNumFiles = newNumFiles
|
||||
s.previousSize = newsize
|
||||
|
||||
scanDuration := int(time.Since(startTime).Seconds())
|
||||
if quick {
|
||||
s.quickScanTime = scanDuration
|
||||
} else {
|
||||
s.fullScanTime = scanDuration
|
||||
s.updateComplexity()
|
||||
if s.processedInodes != nil {
|
||||
s.processedInodes = nil
|
||||
s.foundHardLinks = nil
|
||||
}
|
||||
|
||||
s.idx.mu.Lock()
|
||||
for path := range s.idx.scanUpdatedPaths {
|
||||
if strings.HasPrefix(path, s.scanPath) {
|
||||
delete(s.idx.scanUpdatedPaths, path)
|
||||
}
|
||||
}
|
||||
s.idx.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scanner) runQuickScanChild() {
|
||||
s.scanStartTime = time.Now().Unix()
|
||||
s.filesChanged = false
|
||||
|
||||
// Get folders in this scanner's scope from folderSizes map
|
||||
s.idx.folderSizesMu.RLock()
|
||||
var foldersToCheck []string
|
||||
for path := range s.idx.folderSizes {
|
||||
// Child scanner: check all paths under its scope
|
||||
if strings.HasPrefix(path, s.scanPath) && path != s.scanPath {
|
||||
foldersToCheck = append(foldersToCheck, path)
|
||||
}
|
||||
}
|
||||
s.idx.folderSizesMu.RUnlock()
|
||||
|
||||
batchSize := s.idx.db.BatchSize
|
||||
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
|
||||
|
||||
// Update the scanner's own root folder to prevent it from being purged
|
||||
// (child folders are updated via checkFolderModtime, but the scanner's root needs explicit update)
|
||||
fullScanPath := filepath.Join(s.idx.Path, s.scanPath)
|
||||
if info, err := os.Stat(fullScanPath); err == nil {
|
||||
rootFolderInfo := &iteminfo.FileInfo{
|
||||
Path: s.scanPath,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: filepath.Base(strings.TrimSuffix(s.scanPath, "/")),
|
||||
Size: 0, // Folder size managed in-memory via folderSizes map
|
||||
ModTime: info.ModTime(),
|
||||
Type: "directory",
|
||||
Hidden: false,
|
||||
HasPreview: false,
|
||||
},
|
||||
}
|
||||
// Add to batch for bulk update
|
||||
s.batchItems = append(s.batchItems, rootFolderInfo)
|
||||
}
|
||||
|
||||
// Track stats
|
||||
unchangedCount := 0
|
||||
changedCount := 0
|
||||
errorCount := 0
|
||||
|
||||
// Check each folder's modtime
|
||||
for _, folderPath := range foldersToCheck {
|
||||
changed, err := s.checkFolderModtime(folderPath)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
} else if changed {
|
||||
changedCount++
|
||||
} else {
|
||||
unchangedCount++
|
||||
}
|
||||
}
|
||||
|
||||
s.flushBatch()
|
||||
s.purgeStaleEntries(false, true) // isRoot=false, isQuickScan=true
|
||||
s.syncStatsWithDB()
|
||||
|
||||
}
|
||||
|
||||
// checkForNewChildDirectories detects new top-level directories and creates scanners for them
|
||||
@@ -262,11 +379,14 @@ func (s *Scanner) checkForNewChildDirectories() {
|
||||
}
|
||||
s.idx.mu.RUnlock()
|
||||
|
||||
// Check for deleted directories and stop their scanners
|
||||
for path, scanner := range existingScanners {
|
||||
if !currentDirsMap[path] {
|
||||
logger.Debugf("Directory [%s] no longer exists, stopping scanner", path)
|
||||
scanner.stop()
|
||||
// Remove from map immediately to prevent stale scanner references
|
||||
s.idx.mu.Lock()
|
||||
delete(s.idx.scanners, path)
|
||||
s.idx.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,9 +409,7 @@ func (s *Scanner) checkForNewChildDirectories() {
|
||||
// getTopLevelDirs returns a list of top-level directory paths in the root
|
||||
func (s *Scanner) getTopLevelDirs() []string {
|
||||
dirs := []string{}
|
||||
realPath := strings.TrimRight(s.idx.Path, "/") + "/"
|
||||
|
||||
dir, err := os.Open(realPath)
|
||||
dir, err := os.Open(s.idx.Path)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to open root directory: %v", err)
|
||||
return dirs
|
||||
@@ -306,24 +424,26 @@ func (s *Scanner) getTopLevelDirs() []string {
|
||||
|
||||
for _, file := range files {
|
||||
baseName := file.Name()
|
||||
realPath := utils.JoinPathAsUnix(s.idx.Path, baseName)
|
||||
indexPath := "/" + baseName + "/"
|
||||
if !file.IsDir() {
|
||||
continue
|
||||
}
|
||||
dirPath := "/" + baseName + "/"
|
||||
if omitList[baseName] {
|
||||
logger.Debugf("Skipping scanner creation for omitted directory: %s", dirPath)
|
||||
logger.Debugf("Skipping scanner creation for omitted directory: %s", realPath)
|
||||
continue
|
||||
}
|
||||
if !s.idx.shouldInclude(baseName) {
|
||||
logger.Debugf("Skipping scanner creation for non-included directory: %s", dirPath)
|
||||
logger.Debugf("Skipping scanner creation for non-included directory: %s", realPath)
|
||||
continue
|
||||
}
|
||||
hidden := isHidden(file, s.idx.Path+dirPath)
|
||||
if s.idx.shouldSkip(true, hidden, dirPath, baseName, actionConfig{}) {
|
||||
logger.Debugf("Skipping scanner creation for excluded directory: %s", dirPath)
|
||||
hidden := IsHidden(realPath)
|
||||
isSymlink := file.Mode()&os.ModeSymlink != 0
|
||||
if s.idx.ShouldSkip(true, indexPath, hidden, isSymlink, true) {
|
||||
logger.Debugf("Skipping scanner creation for excluded directory: %s", realPath)
|
||||
continue
|
||||
}
|
||||
dirs = append(dirs, dirPath)
|
||||
dirs = append(dirs, indexPath)
|
||||
}
|
||||
|
||||
return dirs
|
||||
@@ -341,12 +461,19 @@ func (s *Scanner) calculateSleepTime() time.Duration {
|
||||
|
||||
// updateSchedule adjusts the scanner's schedule based on whether files changed
|
||||
func (s *Scanner) updateSchedule() {
|
||||
|
||||
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
|
||||
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
|
||||
if s.idx.Config.IndexingInterval > 0 {
|
||||
s.nextSleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
|
||||
}
|
||||
|
||||
// Adjust schedule based on file changes
|
||||
if s.filesChanged {
|
||||
logger.Debugf("Scanner [%s] detected changes, adjusting schedule", s.scanPath)
|
||||
// Move to at least the full-scan anchor or reduce interval
|
||||
if s.currentSchedule > fullScanAnchor {
|
||||
s.currentSchedule = fullScanAnchor
|
||||
// Move to at least schedule index 3 or reduce interval
|
||||
if s.currentSchedule > 3 {
|
||||
s.currentSchedule = 3
|
||||
} else if s.currentSchedule > 0 {
|
||||
s.currentSchedule--
|
||||
}
|
||||
@@ -369,19 +496,27 @@ func (s *Scanner) updateSchedule() {
|
||||
} else if s.currentSchedule >= len(scanSchedule) {
|
||||
s.currentSchedule = len(scanSchedule) - 1
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
|
||||
// 0: unknown
|
||||
func (s *Scanner) updateComplexity() {
|
||||
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
|
||||
|
||||
// Set smartModifier based on complexity level
|
||||
if modifier, ok := complexityModifier[s.complexity]; ok {
|
||||
s.smartModifier = modifier
|
||||
} else {
|
||||
s.smartModifier = 0
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// directoryExists checks if the scanner's directory still exists
|
||||
@@ -400,15 +535,169 @@ func (s *Scanner) removeSelf() {
|
||||
|
||||
delete(s.idx.scanners, s.scanPath)
|
||||
logger.Infof("Removed scanner [%s] from active scanners", s.scanPath)
|
||||
|
||||
// Trigger stats aggregation to update overall index
|
||||
go s.idx.aggregateStatsFromScanners()
|
||||
}
|
||||
|
||||
// stop gracefully stops the scanner
|
||||
// This method is idempotent and safe to call multiple times
|
||||
// Uses sync.Once to ensure the channel is only closed once, preventing panic from "close of closed channel"
|
||||
func (s *Scanner) stop() {
|
||||
s.stopOnce.Do(func() {
|
||||
close(s.stopChan)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Scanner) syncStatsWithDB() {
|
||||
var dirs, files uint64
|
||||
var err error
|
||||
|
||||
if s.scanPath == "/" {
|
||||
// Root scanner never counts directories (those are handled by child scanners)
|
||||
dirs = 0
|
||||
files, err = s.idx.db.GetDirectFileCount(s.idx.Name, "/")
|
||||
} else {
|
||||
// Child scanner: count directories and files recursively
|
||||
dirs, files, err = s.idx.db.GetRecursiveCount(s.idx.Name, s.scanPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get count for %s: %v", s.scanPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.numDirs = dirs
|
||||
s.numFiles = files
|
||||
}
|
||||
|
||||
// checkFolderModtime checks if a folder's modtime changed since last scan
|
||||
// Returns (changed, error) where changed=true if modtime is after lastScanned, false otherwise
|
||||
func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
|
||||
realPath := strings.TrimRight(s.idx.Path, "/") + folderPath
|
||||
realPath = strings.TrimSuffix(realPath, "/")
|
||||
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Folder was deleted - will be caught by purge
|
||||
return false, nil
|
||||
}
|
||||
logger.Errorf("[QUICK_SCAN] Error stating %s: %v", folderPath, err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Compare folder modtime against when the last scan started
|
||||
// If modtime is after last scan, the folder has been modified
|
||||
modtimeChanged := info.ModTime().After(s.lastScanned)
|
||||
|
||||
if modtimeChanged {
|
||||
// Modtime changed - read directory contents and update database
|
||||
s.filesChanged = true
|
||||
|
||||
dir, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
logger.Errorf("[QUICK_SCAN] Error opening %s: %v", folderPath, err)
|
||||
return true, err
|
||||
}
|
||||
defer dir.Close()
|
||||
|
||||
opts := Options{
|
||||
Recursive: false, // Don't recurse - we're checking each folder individually
|
||||
IsRoutineScan: true,
|
||||
}
|
||||
|
||||
dirInfo, err := s.idx.GetDirInfo(dir, info, folderPath, opts, s)
|
||||
if err != nil {
|
||||
logger.Errorf("[QUICK_SCAN] Error getting dir info for %s: %v", folderPath, err)
|
||||
return true, err
|
||||
}
|
||||
|
||||
// Update metadata (will be batched)
|
||||
s.idx.UpdateMetadata(dirInfo, s)
|
||||
|
||||
// Track this directory as having been updated (for file purge logic)
|
||||
s.idx.mu.Lock()
|
||||
s.idx.scanUpdatedPaths[folderPath] = true
|
||||
s.idx.mu.Unlock()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Modtime unchanged - update last_updated to prevent folder from being purged
|
||||
// Note: we DON'T add this folder to scanUpdatedPaths, so Phase 2 won't check its files
|
||||
fileInfo := &iteminfo.FileInfo{
|
||||
Path: folderPath,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: filepath.Base(strings.TrimSuffix(folderPath, "/")),
|
||||
Size: 0, // Folder size is managed in-memory via folderSizes map
|
||||
ModTime: info.ModTime(),
|
||||
Type: "directory",
|
||||
Hidden: false,
|
||||
HasPreview: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Add to batch for bulk update
|
||||
s.batchItems = append(s.batchItems, fileInfo)
|
||||
if len(s.batchItems) >= s.idx.db.BatchSize {
|
||||
s.flushBatch()
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) purgeStaleEntries(isRoot bool, isQuickScan bool) {
|
||||
if s.scanStartTime == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var deletedCount int
|
||||
var err error
|
||||
|
||||
if isQuickScan {
|
||||
// Quick scan cleanup (two-phase):
|
||||
// Phase 1: Delete folders that weren't updated (they were deleted from filesystem)
|
||||
deletedCount, err = s.idx.db.DeleteStaleFolders(s.idx.Name, s.scanPath, s.scanStartTime, isRoot)
|
||||
if err != nil {
|
||||
logger.Errorf("[DB_MAINTENANCE] Failed to purge stale folders for %s: %v", s.scanPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: Delete files in directories that had modtime changes
|
||||
// Collect list of updated directories from this scan
|
||||
var updatedDirs []string
|
||||
s.idx.mu.RLock()
|
||||
for path := range s.idx.scanUpdatedPaths {
|
||||
// Only include paths within this scanner's scope
|
||||
if s.scanPath == "/" {
|
||||
// Root scanner: only direct children (no nested paths)
|
||||
if strings.Count(path, "/") == 2 && strings.HasPrefix(path, "/") {
|
||||
updatedDirs = append(updatedDirs, path)
|
||||
}
|
||||
} else {
|
||||
// Child scanner: all paths under this scanner's path
|
||||
if strings.HasPrefix(path, s.scanPath) {
|
||||
updatedDirs = append(updatedDirs, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.idx.mu.RUnlock()
|
||||
|
||||
if len(updatedDirs) > 0 {
|
||||
deletedCount, err = s.idx.db.DeleteStaleFilesInDirs(s.idx.Name, updatedDirs, s.scanStartTime)
|
||||
if err != nil {
|
||||
logger.Errorf("[DB_MAINTENANCE] Failed to purge stale files in updated dirs for %s: %v", s.scanPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = nil // Reset err since we handled errors separately above
|
||||
} else {
|
||||
// Full scan cleanup: delete all items not updated in scanner scope
|
||||
deletedCount, err = s.idx.db.DeleteStaleEntries(s.idx.Name, s.scanPath, s.scanStartTime, isRoot)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("[DB_MAINTENANCE] Failed to purge stale entries for %s: %v", s.scanPath, err)
|
||||
return
|
||||
}
|
||||
if deletedCount >= 10000 {
|
||||
logger.Warningf("[DB_MAINTENANCE] high purge count (%d) for %s", deletedCount, s.scanPath)
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,21 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
indexingdb "github.com/gtsteffaniak/filebrowser/backend/database/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/events"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// schedule in minutes
|
||||
var scanSchedule = map[int]time.Duration{
|
||||
0: 5 * time.Minute, // 5 minute quick scan & 25 minutes for a full scan
|
||||
0: 5 * time.Minute, // 5 minute scan
|
||||
1: 10 * time.Minute,
|
||||
2: 20 * time.Minute,
|
||||
3: 40 * time.Minute, // reset anchor for full scan
|
||||
3: 40 * time.Minute, // schedule index 3 for file changes
|
||||
4: 1 * time.Hour,
|
||||
5: 2 * time.Hour,
|
||||
6: 3 * time.Hour, // [6]
|
||||
7: 4 * time.Hour, // [7] 4 hours for quick scan & 20 hours for a full scan
|
||||
6: 3 * time.Hour,
|
||||
7: 4 * time.Hour,
|
||||
8: 8 * time.Hour,
|
||||
9: 12 * time.Hour,
|
||||
}
|
||||
@@ -38,29 +39,29 @@ var complexityModifier = map[uint]time.Duration{
|
||||
10: 16 * time.Minute, // highlyComplex: scan less frequently
|
||||
}
|
||||
|
||||
// calculateTimeScore returns a 1-10 score based on full scan time
|
||||
func calculateTimeScore(fullScanTime int) uint {
|
||||
if fullScanTime == 0 {
|
||||
// calculateTimeScore returns a 1-10 score based on scan time
|
||||
func calculateTimeScore(scanTime int) uint {
|
||||
if scanTime == 0 {
|
||||
return 1 // No data yet, assume simple
|
||||
}
|
||||
switch {
|
||||
case fullScanTime < 2:
|
||||
case scanTime < 2:
|
||||
return 1
|
||||
case fullScanTime < 5:
|
||||
case scanTime < 5:
|
||||
return 2
|
||||
case fullScanTime < 10:
|
||||
case scanTime < 15:
|
||||
return 3
|
||||
case fullScanTime < 15:
|
||||
case scanTime < 30:
|
||||
return 4
|
||||
case fullScanTime < 30:
|
||||
case scanTime < 60:
|
||||
return 5
|
||||
case fullScanTime < 60:
|
||||
case scanTime < 90:
|
||||
return 6
|
||||
case fullScanTime < 90:
|
||||
case scanTime < 120:
|
||||
return 7
|
||||
case fullScanTime < 120:
|
||||
case scanTime < 180:
|
||||
return 8
|
||||
case fullScanTime < 180:
|
||||
case scanTime < 300:
|
||||
return 9
|
||||
default:
|
||||
return 10
|
||||
@@ -94,8 +95,8 @@ func calculateDirScore(numDirs uint64) uint {
|
||||
}
|
||||
}
|
||||
|
||||
func calculateComplexity(fullScanTime int, numDirs uint64) uint {
|
||||
timeScore := calculateTimeScore(fullScanTime)
|
||||
func calculateComplexity(scanTime int, numDirs uint64) uint {
|
||||
timeScore := calculateTimeScore(scanTime)
|
||||
dirScore := calculateDirScore(numDirs)
|
||||
complexity := timeScore
|
||||
if dirScore > timeScore {
|
||||
@@ -104,14 +105,13 @@ func calculateComplexity(fullScanTime int, numDirs uint64) uint {
|
||||
return complexity
|
||||
}
|
||||
|
||||
var fullScanAnchor = 3 // index of the schedule for a full scan
|
||||
|
||||
// Removed: Old single-scanner implementation - replaced by multi-scanner system in indexingScanner.go
|
||||
|
||||
// markFilesChanged marks that files have changed in the currently active scanner
|
||||
func (idx *Index) markFilesChanged() {
|
||||
activePath := idx.getActiveScannerPath()
|
||||
if activePath == "" {
|
||||
return
|
||||
}
|
||||
idx.mu.RLock()
|
||||
activePath := idx.activeScannerPath
|
||||
scanner, exists := idx.scanners[activePath]
|
||||
idx.mu.RUnlock()
|
||||
|
||||
@@ -123,7 +123,12 @@ func (idx *Index) markFilesChanged() {
|
||||
// incrementScannerDirs increments the directory counter for the active scanner
|
||||
func (idx *Index) incrementScannerDirs() {
|
||||
idx.mu.RLock()
|
||||
activePath := idx.activeScannerPath
|
||||
// Use unlocked version since we now hold the lock
|
||||
activePath := idx.getActiveScannerPathUnlocked()
|
||||
if activePath == "" {
|
||||
idx.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
scanner, exists := idx.scanners[activePath]
|
||||
idx.mu.RUnlock()
|
||||
|
||||
@@ -132,10 +137,27 @@ func (idx *Index) incrementScannerDirs() {
|
||||
}
|
||||
}
|
||||
|
||||
// incrementScannerDirsUnlocked increments the directory counter for the active scanner
|
||||
func (idx *Index) incrementScannerDirsUnlocked() {
|
||||
activePath := idx.getActiveScannerPathUnlocked()
|
||||
if activePath == "" {
|
||||
return
|
||||
}
|
||||
scanner, exists := idx.scanners[activePath]
|
||||
if exists {
|
||||
scanner.numDirs++
|
||||
}
|
||||
}
|
||||
|
||||
// incrementScannerFiles increments the file counter for the active scanner
|
||||
func (idx *Index) incrementScannerFiles() {
|
||||
idx.mu.RLock()
|
||||
activePath := idx.activeScannerPath
|
||||
// Use unlocked version since we now hold the lock
|
||||
activePath := idx.getActiveScannerPathUnlocked()
|
||||
if activePath == "" {
|
||||
idx.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
scanner, exists := idx.scanners[activePath]
|
||||
idx.mu.RUnlock()
|
||||
|
||||
@@ -149,30 +171,35 @@ func (idx *Index) PreScan() error {
|
||||
}
|
||||
|
||||
func (idx *Index) PostScan() error {
|
||||
idx.mu.Lock()
|
||||
idx.garbageCollection()
|
||||
idx.wasIndexed = true
|
||||
idx.runningScannerCount--
|
||||
idx.mu.Unlock()
|
||||
if idx.runningScannerCount == 0 {
|
||||
// Only do expensive operations when ALL scanners are done
|
||||
if idx.getRunningScannerCount() == 0 {
|
||||
// All scanners completed
|
||||
err := idx.db.ShrinkMemory()
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to shrink memory: %v", err)
|
||||
}
|
||||
|
||||
// Persist index and scanner information
|
||||
if err := idx.Save(); err != nil {
|
||||
logger.Errorf("Failed to save index persistence: %v", err)
|
||||
}
|
||||
|
||||
// Send update event to notify frontend that stats may have changed
|
||||
if err := idx.SendSourceUpdateEvent(); err != nil {
|
||||
logger.Errorf("Error sending source update event: %v", err)
|
||||
}
|
||||
|
||||
// Clear scan session tracking when all scanners complete
|
||||
idx.mu.Lock()
|
||||
idx.scanSessionStartTime = 0
|
||||
idx.scanUpdatedPaths = make(map[string]bool) // Clear tracking map
|
||||
idx.mu.Unlock()
|
||||
return idx.SetStatus(READY)
|
||||
}
|
||||
// Scanners still running - skip expensive operations
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx *Index) garbageCollection() {
|
||||
for path := range idx.Directories {
|
||||
_, ok := idx.DirectoriesLedger[path]
|
||||
if !ok {
|
||||
idx.Directories[path] = nil
|
||||
delete(idx.Directories, path)
|
||||
idx.NumDeleted++
|
||||
}
|
||||
}
|
||||
// Reset the ledger for the next scan.
|
||||
idx.DirectoriesLedger = make(map[string]struct{})
|
||||
}
|
||||
|
||||
func (idx *Index) SendSourceUpdateEvent() error {
|
||||
if idx.mock {
|
||||
logger.Debug("Skipping source update event for mock index.")
|
||||
@@ -191,26 +218,55 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
logger.Errorf("[%s] Error marshaling source update: %v", idx.Name, err)
|
||||
return err
|
||||
}
|
||||
// Quote the JSON string so it's sent as a string in the SSE message, not as an object
|
||||
// The sendEvent function expects the message to be properly quoted (like "\"connection established\"")
|
||||
quotedMessage := strconv.Quote(string(message))
|
||||
events.SendSourceUpdate(idx.Name, quotedMessage)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupMultiScanner creates and starts the multi-scanner system
|
||||
// Creates a root scanner (non-recursive) and child scanners for each top-level directory
|
||||
func (idx *Index) setupMultiScanner() {
|
||||
logger.Infof("Setting up multi-scanner system for [%v]", idx.Name)
|
||||
// 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.initialScanStartTime = time.Now()
|
||||
idx.hasLoggedInitialScan = false
|
||||
idx.mu.Unlock()
|
||||
|
||||
// Load persisted scanner info if available
|
||||
var persistedScanners map[string]*indexingdb.PersistedScannerInfo
|
||||
if indexingStorage != nil {
|
||||
info, err := indexingStorage.GetByPath(idx.Path)
|
||||
if err == nil && info != nil {
|
||||
persistedScanners = info.Scanners
|
||||
}
|
||||
}
|
||||
|
||||
// Create and start root scanner
|
||||
rootScanner := idx.createRootScanner()
|
||||
// Restore persisted stats for root scanner if available
|
||||
if persistedScanners != nil {
|
||||
if rootInfo, ok := persistedScanners["/"]; ok {
|
||||
rootScanner.complexity = rootInfo.Complexity
|
||||
rootScanner.currentSchedule = rootInfo.CurrentSchedule
|
||||
rootScanner.quickScanTime = rootInfo.QuickScanTime
|
||||
rootScanner.fullScanTime = rootInfo.FullScanTime
|
||||
rootScanner.numDirs = rootInfo.NumDirs
|
||||
rootScanner.numFiles = rootInfo.NumFiles
|
||||
rootScanner.lastScanned = rootInfo.LastScanned
|
||||
}
|
||||
}
|
||||
idx.mu.Lock()
|
||||
idx.scanners["/"] = rootScanner
|
||||
idx.mu.Unlock()
|
||||
@@ -223,6 +279,19 @@ func (idx *Index) setupMultiScanner() {
|
||||
for _, dirPath := range topLevelDirs {
|
||||
childScanner := idx.createChildScanner(dirPath)
|
||||
|
||||
// Restore persisted stats for child scanner if available
|
||||
if persistedScanners != nil {
|
||||
if childInfo, ok := persistedScanners[dirPath]; ok {
|
||||
childScanner.complexity = childInfo.Complexity
|
||||
childScanner.currentSchedule = childInfo.CurrentSchedule
|
||||
childScanner.quickScanTime = childInfo.QuickScanTime
|
||||
childScanner.fullScanTime = childInfo.FullScanTime
|
||||
childScanner.numDirs = childInfo.NumDirs
|
||||
childScanner.numFiles = childInfo.NumFiles
|
||||
childScanner.lastScanned = childInfo.LastScanned
|
||||
}
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
idx.scanners[dirPath] = childScanner
|
||||
idx.mu.Unlock()
|
||||
@@ -263,90 +332,6 @@ func (idx *Index) setupIndexingScanners() {
|
||||
idx.setupMultiScanner()
|
||||
}
|
||||
|
||||
// aggregateStatsFromScanners aggregates stats from all scanners to Index-level stats
|
||||
func (idx *Index) aggregateStatsFromScanners() {
|
||||
idx.mu.Lock()
|
||||
|
||||
if len(idx.scanners) == 0 {
|
||||
idx.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Store previous stats and status to detect changes
|
||||
// Use stored previous totalSize for change detection
|
||||
prevNumDirs := idx.NumDirs
|
||||
prevNumFiles := idx.NumFiles
|
||||
prevDiskUsed := idx.previousTotalSize // Use stored previous value (0 on first call, which will trigger initial event)
|
||||
prevStatus := idx.Status
|
||||
|
||||
// Aggregate stats from all scanners
|
||||
var totalDirs uint64 = 0
|
||||
var totalFiles uint64 = 0
|
||||
var totalQuickScanTime = 0
|
||||
var totalFullScanTime = 0
|
||||
var totalSizeFromScanners uint64 = 0 // Sum of all scanner sizes
|
||||
var mostRecentScan time.Time
|
||||
allScannedAtLeastOnce := true
|
||||
|
||||
for _, scanner := range idx.scanners {
|
||||
totalDirs += scanner.numDirs
|
||||
totalFiles += scanner.numFiles
|
||||
totalQuickScanTime += scanner.quickScanTime
|
||||
totalFullScanTime += scanner.fullScanTime
|
||||
totalSizeFromScanners += scanner.size
|
||||
if scanner.lastScanned.After(mostRecentScan) {
|
||||
mostRecentScan = scanner.lastScanned
|
||||
}
|
||||
if scanner.lastScanned.IsZero() {
|
||||
allScannedAtLeastOnce = false
|
||||
}
|
||||
}
|
||||
|
||||
// Update totalSize by summing all scanner sizes (root scanner handles this)
|
||||
// This ensures totalSize is always the sum of all scanner contributions
|
||||
idx.totalSize = totalSizeFromScanners
|
||||
idx.NumDirs = totalDirs
|
||||
idx.NumFiles = totalFiles
|
||||
idx.QuickScanTime = totalQuickScanTime
|
||||
idx.FullScanTime = totalFullScanTime
|
||||
if allScannedAtLeastOnce {
|
||||
idx.Complexity = calculateComplexity(totalFullScanTime, totalDirs)
|
||||
} else {
|
||||
idx.Complexity = 0
|
||||
}
|
||||
if !mostRecentScan.IsZero() {
|
||||
idx.LastIndexed = mostRecentScan
|
||||
idx.LastIndexedUnix = mostRecentScan.Unix()
|
||||
idx.wasIndexed = true
|
||||
}
|
||||
if allScannedAtLeastOnce && !idx.hasLoggedInitialScan {
|
||||
totalDuration := time.Since(idx.initialScanStartTime)
|
||||
truncatedToSecond := totalDuration.Truncate(time.Second)
|
||||
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Name, truncatedToSecond)
|
||||
idx.hasLoggedInitialScan = true
|
||||
}
|
||||
if allScannedAtLeastOnce && idx.activeScannerPath == "" {
|
||||
idx.Status = READY
|
||||
} else if idx.activeScannerPath != "" {
|
||||
idx.Status = INDEXING
|
||||
}
|
||||
newDiskUsed := idx.totalSize
|
||||
newStatus := idx.Status
|
||||
idx.mu.Unlock()
|
||||
statsChanged := prevNumDirs != totalDirs || prevNumFiles != totalFiles || prevDiskUsed != newDiskUsed
|
||||
statusChanged := prevStatus != newStatus
|
||||
if statsChanged || statusChanged {
|
||||
err := idx.SendSourceUpdateEvent()
|
||||
if err != nil {
|
||||
logger.Errorf("Error sending source update event: %v", err)
|
||||
}
|
||||
// Update previousTotalSize after sending event so next aggregation can detect changes
|
||||
idx.mu.Lock()
|
||||
idx.previousTotalSize = newDiskUsed
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// GetScannerStatus returns detailed information about all active scanners
|
||||
func (idx *Index) GetScannerStatus() map[string]interface{} {
|
||||
idx.mu.RLock()
|
||||
@@ -354,9 +339,9 @@ func (idx *Index) GetScannerStatus() map[string]interface{} {
|
||||
|
||||
status := make(map[string]interface{})
|
||||
|
||||
// Current active scanner (if any is running)
|
||||
status["activeScanner"] = idx.activeScannerPath
|
||||
status["isScanning"] = idx.activeScannerPath != ""
|
||||
activePath := idx.getActiveScannerPathUnlocked()
|
||||
status["activeScanner"] = activePath
|
||||
status["isScanning"] = activePath != ""
|
||||
|
||||
// Individual scanner stats
|
||||
scannerStats := make([]map[string]interface{}, 0, len(idx.scanners))
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
)
|
||||
|
||||
func BenchmarkFillIndex(b *testing.B) {
|
||||
@@ -55,6 +55,15 @@ func TestGetIndex(t *testing.T) {
|
||||
|
||||
// TestMultiScannerMutex verifies that only one scanner can run at a time
|
||||
func TestMultiScannerMutex(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_multiscanner", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a mock index with multi-scanner support
|
||||
idx := &Index{
|
||||
Source: settings.Source{
|
||||
@@ -64,12 +73,9 @@ func TestMultiScannerMutex(t *testing.T) {
|
||||
DisableIndexing: false,
|
||||
},
|
||||
},
|
||||
mock: true,
|
||||
Directories: make(map[string]*iteminfo.FileInfo),
|
||||
DirectoriesLedger: make(map[string]struct{}),
|
||||
processedInodes: make(map[uint64]struct{}),
|
||||
FoundHardLinks: make(map[string]uint64),
|
||||
scanners: make(map[string]*Scanner),
|
||||
mock: true,
|
||||
db: indexDB,
|
||||
scanners: make(map[string]*Scanner),
|
||||
}
|
||||
|
||||
// Create multiple scanners
|
||||
@@ -107,7 +113,9 @@ func TestMultiScannerMutex(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify scanner counters can be updated
|
||||
idx.activeScannerPath = "/test1/"
|
||||
// Test with active scanner - mark scanner2 as scanning
|
||||
scanner2.isScanning = true
|
||||
idx.scanners = map[string]*Scanner{"/test1/": scanner2}
|
||||
idx.incrementScannerDirs()
|
||||
idx.incrementScannerFiles()
|
||||
|
||||
@@ -149,7 +157,7 @@ func TestMakeIndexPath(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := &Index{Source: settings.Source{Path: "/srv"}, mock: true}
|
||||
result := idx.MakeIndexPath(tt.subPath)
|
||||
result := idx.MakeIndexPath(tt.subPath, true) // All test paths are directories
|
||||
if result != tt.expected {
|
||||
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
|
||||
}
|
||||
@@ -170,7 +178,7 @@ func TestMakeIndexPath(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := &Index{Source: settings.Source{Path: "C:\\Users"}, mock: true}
|
||||
result := idx.MakeIndexPath(tt.subPath)
|
||||
result := idx.MakeIndexPath(tt.subPath, true) // All test paths are directories
|
||||
if result != tt.expected {
|
||||
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
|
||||
}
|
||||
@@ -191,7 +199,7 @@ func TestMakeIndexPathRoot(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
idx := &Index{Source: settings.Source{Path: "/rootpath", Name: "default"}, mock: true}
|
||||
result := idx.MakeIndexPath(tt.subPath)
|
||||
result := idx.MakeIndexPath(tt.subPath, true) // Root path is a directory
|
||||
if result != tt.expected {
|
||||
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
|
||||
}
|
||||
@@ -242,7 +250,7 @@ func TestCheckIndexExclude(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
result := idx.shouldSkip(tc.isDir, tc.isHidden, tc.fullPath, tc.baseName, nil)
|
||||
result := idx.ShouldSkip(tc.isDir, tc.fullPath, tc.isHidden, false, false)
|
||||
if result != tc.expect {
|
||||
t.Errorf("shouldSkip(%v, %v, %q, %q) = %v; want %v", tc.isDir, tc.isHidden, tc.fullPath, tc.baseName, result, tc.expect)
|
||||
}
|
||||
|
||||
@@ -55,5 +55,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:"-"`
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func (idx *Index) CreateMockData(numDirs, numFilesPerDir int) {
|
||||
Files: files,
|
||||
}
|
||||
|
||||
idx.UpdateMetadata(dirInfo)
|
||||
idx.UpdateMetadata(dirInfo, nil) // nil scanner for mock
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+233
-115
@@ -13,79 +13,124 @@ import (
|
||||
)
|
||||
|
||||
// UpdateFileMetadata updates the FileInfo for the specified directory in the index.
|
||||
func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo) bool {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
idx.Directories[info.Path] = info
|
||||
return true
|
||||
}
|
||||
// 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
|
||||
items = append(items, &dirItem)
|
||||
|
||||
// DeleteMetadata removes the specified path from the index.
|
||||
// If recursive is true and the path is a directory, it will also remove all subdirectories.
|
||||
func (idx *Index) DeleteMetadata(path string, isDir bool, recursive bool) bool {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
// Check if we're in a batch scan (scanner provided)
|
||||
isBatchScan := scanner != nil
|
||||
|
||||
// Normalize the path - ensure trailing slash for directories
|
||||
indexPath := path
|
||||
if isDir {
|
||||
indexPath = utils.AddTrailingSlashIfNotExists(path)
|
||||
if isBatchScan {
|
||||
// During recursive scans: only insert directory itself and direct files
|
||||
// Subdirectories will be inserted by their own recursive indexDirectory call
|
||||
// This avoids redundant inserts and maintains the in-memory size calculation pattern
|
||||
for i := range info.Files {
|
||||
f := &info.Files[i]
|
||||
// Construct index path for the file
|
||||
filePath := strings.TrimRight(info.Path, "/") + "/" + f.Name
|
||||
|
||||
fileItem := &iteminfo.FileInfo{
|
||||
ItemInfo: f.ItemInfo,
|
||||
Path: filePath, // Store index path, not absolute path
|
||||
}
|
||||
items = append(items, fileItem)
|
||||
}
|
||||
} else {
|
||||
// Not in batch scan (e.g., API-triggered updates): insert everything
|
||||
// Add folders to the bulk insert with index paths
|
||||
for i := range info.Folders {
|
||||
folder := &info.Folders[i]
|
||||
// Construct index path for the folder (with trailing slash)
|
||||
folderPath := strings.TrimRight(info.Path, "/") + "/" + folder.Name + "/"
|
||||
|
||||
folderItem := &iteminfo.FileInfo{
|
||||
ItemInfo: *folder,
|
||||
Path: folderPath, // Store index path with trailing slash
|
||||
}
|
||||
items = append(items, folderItem)
|
||||
}
|
||||
|
||||
// Add files to the bulk insert with index paths
|
||||
for i := range info.Files {
|
||||
f := &info.Files[i]
|
||||
// Construct index path for the file
|
||||
filePath := strings.TrimRight(info.Path, "/") + "/" + f.Name
|
||||
|
||||
fileItem := &iteminfo.FileInfo{
|
||||
ItemInfo: f.ItemInfo,
|
||||
Path: filePath, // Store index path, not absolute path
|
||||
}
|
||||
items = append(items, fileItem)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache entries
|
||||
joinedPath := filepath.Join(idx.Path, indexPath)
|
||||
RealPathCache.Delete(joinedPath)
|
||||
IsDirCache.Delete(joinedPath + ":isdir")
|
||||
// Use scanner-specific batch if available
|
||||
if scanner != nil {
|
||||
// Track that this directory was updated by the scan (for timestamp conflict detection)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(info.Path)
|
||||
idx.mu.Lock()
|
||||
idx.scanUpdatedPaths[normalizedPath] = true
|
||||
idx.mu.Unlock()
|
||||
|
||||
if !isDir {
|
||||
// For files, remove from parent directory's Files slice
|
||||
parentPath := utils.AddTrailingSlashIfNotExists(filepath.Dir(strings.TrimSuffix(path, "/")))
|
||||
if parentDir, exists := idx.Directories[parentPath]; exists {
|
||||
fileName := filepath.Base(strings.TrimSuffix(path, "/"))
|
||||
for i, file := range parentDir.Files {
|
||||
if file.Name == fileName {
|
||||
// Remove file from slice
|
||||
parentDir.Files = append(parentDir.Files[:i], parentDir.Files[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Accumulate items for bulk insert in scanner's batch
|
||||
scanner.batchItems = append(scanner.batchItems, items...)
|
||||
// Progressive flushing: flush every BATCH_SIZE items to keep memory bounded
|
||||
if len(scanner.batchItems) >= idx.db.BatchSize {
|
||||
scanner.flushBatch()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// For directories
|
||||
if recursive {
|
||||
// Remove all subdirectories that start with this path
|
||||
toDelete := []string{}
|
||||
for dirPath := range idx.Directories {
|
||||
// Match exact path or any subdirectory
|
||||
if dirPath == indexPath || strings.HasPrefix(dirPath, indexPath) {
|
||||
toDelete = append(toDelete, dirPath)
|
||||
}
|
||||
}
|
||||
for _, dirPath := range toDelete {
|
||||
delete(idx.Directories, dirPath)
|
||||
delete(idx.DirectoriesLedger, dirPath)
|
||||
}
|
||||
} else {
|
||||
// Just remove this specific directory
|
||||
delete(idx.Directories, indexPath)
|
||||
delete(idx.DirectoriesLedger, indexPath)
|
||||
// Not in a batch scan - insert immediately (e.g., API-triggered updates)
|
||||
if err := idx.db.BulkInsertItems(idx.Name, items); err != nil {
|
||||
logger.Errorf("Failed to update metadata for %s: %v", info.Path, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// flushBatch writes all remaining batch items to the database (Scanner method)
|
||||
// This is called at the end of a scan to flush any items that didn't reach the BATCH_SIZE threshold
|
||||
// Also clears processedInodes/foundHardLinks maps periodically to prevent unbounded memory growth
|
||||
func (s *Scanner) flushBatch() {
|
||||
items := s.batchItems
|
||||
s.batchItems = nil
|
||||
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from parent directory's Folders slice
|
||||
if indexPath != "/" {
|
||||
parentPath := utils.AddTrailingSlashIfNotExists(filepath.Dir(strings.TrimSuffix(indexPath, "/")))
|
||||
if parentDir, exists := idx.Directories[parentPath]; exists {
|
||||
dirName := filepath.Base(strings.TrimSuffix(indexPath, "/"))
|
||||
for i, folder := range parentDir.Folders {
|
||||
if folder.Name == dirName {
|
||||
// Remove folder from slice
|
||||
parentDir.Folders = append(parentDir.Folders[:i], parentDir.Folders[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
err := s.idx.db.BulkInsertItems(s.idx.Name, items)
|
||||
if err != nil {
|
||||
logger.Warningf("[DB_TX] Flush failed for scanner [%s] (%d items): %v", s.scanPath, len(items), err)
|
||||
}
|
||||
|
||||
// Clear hardlink tracking maps periodically to prevent unbounded memory growth
|
||||
// Clear after every batch flush (every BatchSize items) to keep memory bounded
|
||||
// For large filesystems with many hardlinks, this prevents maps from growing to millions of entries
|
||||
if len(s.processedInodes) > 100000 {
|
||||
s.processedInodes = make(map[uint64]struct{})
|
||||
s.foundHardLinks = make(map[string]uint64)
|
||||
logger.Debugf("[MEMORY] Cleared hardlink tracking maps for scanner [%s] (prevented unbounded growth)", s.scanPath)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteMetadata removes the specified path from the index.
|
||||
// SQLite handles locking automatically, so no mutex needed.
|
||||
func (idx *Index) DeleteMetadata(path string, isDir bool, recursive bool) bool {
|
||||
// Normalize the path - ensure trailing slash for directories
|
||||
indexPath := path
|
||||
if isDir {
|
||||
indexPath = utils.AddTrailingSlashIfNotExists(path)
|
||||
} else {
|
||||
indexPath = strings.TrimSuffix(indexPath, "/")
|
||||
}
|
||||
// indexPath is already an index path (relative to source root)
|
||||
if err := idx.db.DeleteItem(idx.Name, indexPath, recursive); err != nil {
|
||||
logger.Errorf("Failed to delete metadata for %s: %v", indexPath, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -93,58 +138,108 @@ func (idx *Index) DeleteMetadata(path string, isDir bool, recursive bool) bool {
|
||||
|
||||
// GetMetadataInfo retrieves the FileInfo from the specified file or directory in the index.
|
||||
func (idx *Index) GetReducedMetadata(target string, isDir bool) (*iteminfo.FileInfo, bool) {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
checkDir := idx.MakeIndexPath(target)
|
||||
if !isDir {
|
||||
checkDir = idx.MakeIndexPath(filepath.Dir(target))
|
||||
|
||||
checkPath := idx.MakeIndexPath(target, isDir)
|
||||
|
||||
if checkPath == "" {
|
||||
checkPath = "/"
|
||||
}
|
||||
if checkDir == "" {
|
||||
checkDir = "/"
|
||||
}
|
||||
dir, exists := idx.Directories[checkDir]
|
||||
if !exists {
|
||||
|
||||
// checkPath is already an index path (relative to source root)
|
||||
item, err := idx.db.GetItem(idx.Name, checkPath)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if isDir {
|
||||
return dir, true
|
||||
// Guard against nil item (can happen if DB was busy/locked and GetItem returned nil, nil)
|
||||
if item == nil {
|
||||
return nil, false
|
||||
}
|
||||
// handle file
|
||||
baseName := filepath.Base(target)
|
||||
for _, item := range dir.Files {
|
||||
if item.Name == baseName {
|
||||
// Use path.Join to properly handle trailing slashes and avoid double slashes
|
||||
var fp string
|
||||
if checkDir == "/" {
|
||||
fp = "/" + item.Name
|
||||
} else {
|
||||
// Clean path to remove any trailing slashes before joining
|
||||
fp = strings.TrimSuffix(checkDir, "/") + "/" + item.Name
|
||||
}
|
||||
return &iteminfo.FileInfo{
|
||||
Path: fp,
|
||||
ItemInfo: item.ItemInfo,
|
||||
}, true
|
||||
|
||||
// If this is a directory, populate size from in-memory map (prefer in-memory over DB)
|
||||
if item.Type == "directory" {
|
||||
dirPath := utils.AddTrailingSlashIfNotExists(checkPath)
|
||||
inMemSize, exists := idx.GetFolderSize(dirPath)
|
||||
if !exists {
|
||||
inMemSize = 0
|
||||
}
|
||||
if inMemSize > 0 || item.Size == 0 {
|
||||
item.Size = int64(inMemSize)
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
|
||||
return item, true
|
||||
}
|
||||
|
||||
// raw directory info retrieval -- does not work on files, only returns a directory
|
||||
func (idx *Index) GetMetadataInfo(target string, isDir bool) (*iteminfo.FileInfo, bool) {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
checkDir := idx.MakeIndexPath(target)
|
||||
func (idx *Index) GetMetadataInfo(target string, isDir bool, shallow bool) (*iteminfo.FileInfo, bool) {
|
||||
|
||||
var checkDir string
|
||||
if !isDir {
|
||||
checkDir = idx.MakeIndexPath(filepath.Dir(target))
|
||||
checkDir = idx.MakeIndexPath(filepath.Dir(target), true)
|
||||
} else {
|
||||
checkDir = idx.MakeIndexPath(target, true)
|
||||
}
|
||||
|
||||
if checkDir == "" {
|
||||
checkDir = "/"
|
||||
}
|
||||
dir, exists := idx.Directories[checkDir]
|
||||
return dir, exists
|
||||
|
||||
// checkDir is already an index path (relative to source root)
|
||||
dir, err := idx.db.GetItem(idx.Name, checkDir)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// If not found in DB during scan, item might not be indexed yet
|
||||
// (Batches are now scanner-specific, so we don't flush from API calls)
|
||||
if dir == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// If shallow is true, return only the parent item without fetching children
|
||||
// This is much faster when we only need fields like hasPreview
|
||||
if shallow {
|
||||
return dir, true
|
||||
}
|
||||
|
||||
// Get children
|
||||
children, err := idx.db.GetDirectoryChildren(idx.Name, checkDir)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to get children for %s: %v", checkDir, err)
|
||||
return dir, true
|
||||
}
|
||||
|
||||
// Populate Files and Folders
|
||||
for _, child := range children {
|
||||
if child.Type == "directory" {
|
||||
// Populate directory size from in-memory map (prefer in-memory over DB)
|
||||
childPath := utils.AddTrailingSlashIfNotExists(child.Path)
|
||||
inMemSize, exists := idx.GetFolderSize(childPath)
|
||||
if !exists {
|
||||
inMemSize = 0
|
||||
}
|
||||
if inMemSize > 0 || child.Size == 0 {
|
||||
// Use in-memory if available, or if DB also shows 0
|
||||
child.Size = int64(inMemSize)
|
||||
}
|
||||
// else: keep DB value if in-memory is 0 but DB has a value
|
||||
dir.Folders = append(dir.Folders, child.ItemInfo)
|
||||
} else {
|
||||
dir.Files = append(dir.Files, iteminfo.ExtendedItemInfo{ItemInfo: child.ItemInfo})
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the directory's own size from in-memory map (prefer in-memory over DB)
|
||||
inMemSize, exists := idx.GetFolderSize(checkDir)
|
||||
if !exists {
|
||||
inMemSize = 0
|
||||
}
|
||||
if inMemSize > 0 || dir.Size == 0 {
|
||||
dir.Size = int64(inMemSize)
|
||||
}
|
||||
|
||||
return dir, true
|
||||
}
|
||||
|
||||
func GetIndex(name string) *Index {
|
||||
@@ -169,17 +264,28 @@ func GetIndex(name string) *Index {
|
||||
}
|
||||
|
||||
// ReadOnlyOperation executes a function with read-only access to the index
|
||||
// This provides a safe way to access index data without exposing internal structures
|
||||
func (idx *Index) ReadOnlyOperation(fn func()) {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
fn()
|
||||
}
|
||||
|
||||
// GetDirectories returns the directories map for read-only access
|
||||
// Should only be called within ReadOnlyOperation
|
||||
func (idx *Index) GetDirectories() map[string]*iteminfo.FileInfo {
|
||||
return idx.Directories
|
||||
// IterateFiles iterates over all files in the index and calls the callback function
|
||||
func (idx *Index) IterateFiles(fn func(path, name string, size, modTime int64)) error {
|
||||
rows, err := idx.db.Query("SELECT path, name, size, mod_time FROM index_items WHERE source = ? AND is_dir = 0", idx.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var path, name string
|
||||
var size, modTime int64
|
||||
if err := rows.Scan(&path, &name, &size, &modTime); err != nil {
|
||||
return err
|
||||
}
|
||||
fn(path, name, size, modTime)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, error) {
|
||||
@@ -217,24 +323,36 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
|
||||
for _, scanner := range idx.scanners {
|
||||
scannerInfos = append(scannerInfos, &ScannerInfo{
|
||||
Path: scanner.scanPath,
|
||||
LastScanned: scanner.lastScanned,
|
||||
Complexity: scanner.complexity,
|
||||
CurrentSchedule: scanner.currentSchedule,
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
idx.mu.RUnlock()
|
||||
|
||||
// Get fresh values from the index (with lock to ensure consistency)
|
||||
idx.mu.RLock()
|
||||
reducedIdx := idx.ReducedIndex
|
||||
// Ensure DiskUsed is up to date from totalSize
|
||||
reducedIdx.DiskUsed = idx.totalSize
|
||||
// Compute DiskUsed from database (total size of all files)
|
||||
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.DiskTotal = idx.DiskTotal
|
||||
reducedIdx.Scanners = scannerInfos
|
||||
reducedIdx.NumDirs = idx.getNumDirsUnlocked()
|
||||
reducedIdx.NumFiles = idx.getNumFilesUnlocked()
|
||||
reducedIdx.QuickScanTime = idx.getQuickScanTimeUnlocked()
|
||||
reducedIdx.FullScanTime = idx.getFullScanTimeUnlocked()
|
||||
reducedIdx.Complexity = idx.getComplexityUnlocked()
|
||||
lastIndexed := idx.getLastIndexedUnlocked()
|
||||
reducedIdx.LastScanned = lastIndexed
|
||||
reducedIdx.LastIndexedUnix = lastIndexed.Unix()
|
||||
reducedIdx.Status = idx.getStatusUnlocked()
|
||||
idx.mu.RUnlock()
|
||||
return reducedIdx, nil
|
||||
}
|
||||
+166
-77
@@ -1,18 +1,106 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var testIndex Index
|
||||
var (
|
||||
testIndex Index
|
||||
testIndexInitialized bool
|
||||
testIndexSetupMutex sync.Mutex
|
||||
)
|
||||
|
||||
func setupMutateTestIndex(t *testing.T) *Index {
|
||||
t.Helper()
|
||||
|
||||
testIndexSetupMutex.Lock()
|
||||
defer testIndexSetupMutex.Unlock()
|
||||
|
||||
if testIndexInitialized {
|
||||
return &testIndex
|
||||
}
|
||||
|
||||
var err error
|
||||
testDB, err := dbsql.NewIndexDB("test_init", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
testIndex = Index{
|
||||
ReducedIndex: ReducedIndex{
|
||||
Stats: Stats{
|
||||
NumFiles: 10,
|
||||
NumDirs: 5,
|
||||
},
|
||||
},
|
||||
Source: settings.Source{
|
||||
Path: "/",
|
||||
Name: "test",
|
||||
},
|
||||
db: testDB,
|
||||
mock: true,
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
testpath := &iteminfo.FileInfo{
|
||||
Path: "/testpath/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
ModTime: now,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt", Size: 100, ModTime: now}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "anotherfile.txt", Size: 100, ModTime: now}},
|
||||
},
|
||||
}
|
||||
err = testDB.BulkInsertItems("test", []*iteminfo.FileInfo{
|
||||
testpath,
|
||||
{Path: "/testpath/testfile.txt", ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt", Size: 100, ModTime: now}},
|
||||
{Path: "/testpath/anotherfile.txt", ItemInfo: iteminfo.ItemInfo{Name: "anotherfile.txt", Size: 100, ModTime: now}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert test data for /testpath/: %v", err)
|
||||
}
|
||||
|
||||
anotherpath := &iteminfo.FileInfo{
|
||||
Path: "/anotherpath/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "anotherpath",
|
||||
Type: "directory",
|
||||
ModTime: now,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "afile.txt", Size: 100, ModTime: now}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "directory", Type: "directory", Size: 100},
|
||||
},
|
||||
}
|
||||
err = testDB.BulkInsertItems("test", []*iteminfo.FileInfo{
|
||||
anotherpath,
|
||||
{Path: "/anotherpath/afile.txt", ItemInfo: iteminfo.ItemInfo{Name: "afile.txt", Size: 100, ModTime: now}},
|
||||
{Path: "/anotherpath/directory/", ItemInfo: iteminfo.ItemInfo{Name: "directory", Type: "directory", Size: 100, ModTime: now}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert test data for /anotherpath/: %v", err)
|
||||
}
|
||||
|
||||
testIndexInitialized = true
|
||||
return &testIndex
|
||||
}
|
||||
|
||||
// Test for GetFileMetadata// Test for GetFileMetadata
|
||||
func TestGetFileMetadataSize(t *testing.T) {
|
||||
idx := setupMutateTestIndex(t)
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
adjustedPath string
|
||||
@@ -26,7 +114,7 @@ func TestGetFileMetadataSize(t *testing.T) {
|
||||
expectedSize: 100,
|
||||
},
|
||||
{
|
||||
name: "testpath exists",
|
||||
name: "testpath exists directory",
|
||||
adjustedPath: "/testpath",
|
||||
expectedName: "directory",
|
||||
expectedSize: 100,
|
||||
@@ -34,10 +122,12 @@ func TestGetFileMetadataSize(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fileInfo, _ := testIndex.GetReducedMetadata(tt.adjustedPath, true)
|
||||
// Iterate over iteminfo.Items to look for expectedName
|
||||
t.Parallel()
|
||||
fileInfo, exists := idx.GetReducedMetadata(tt.adjustedPath, true)
|
||||
if !exists || fileInfo == nil {
|
||||
t.Fatalf("Failed to get metadata for %s", tt.adjustedPath)
|
||||
}
|
||||
for _, item := range fileInfo.Files {
|
||||
// Assert the existence and the name
|
||||
if item.Name == tt.expectedName {
|
||||
assert.Equal(t, tt.expectedSize, item.Size)
|
||||
break
|
||||
@@ -47,9 +137,10 @@ func TestGetFileMetadataSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test for GetFileMetadata// Test for GetFileMetadata
|
||||
func TestGetFileMetadata(t *testing.T) {
|
||||
idx := setupMutateTestIndex(t)
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
adjustedPath string
|
||||
@@ -91,7 +182,8 @@ func TestGetFileMetadata(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fileInfo, exists := testIndex.GetReducedMetadata(tt.adjustedPath, tt.isDir)
|
||||
t.Parallel()
|
||||
fileInfo, exists := idx.GetReducedMetadata(tt.adjustedPath, tt.isDir)
|
||||
if !exists {
|
||||
found := false
|
||||
assert.Equal(t, tt.expectedExists, found)
|
||||
@@ -99,9 +191,7 @@ func TestGetFileMetadata(t *testing.T) {
|
||||
}
|
||||
found := false
|
||||
if tt.isDir {
|
||||
// Iterate over iteminfo.Items to look for expectedName
|
||||
for _, item := range fileInfo.Files {
|
||||
// Assert the existence and the name
|
||||
if item.Name == tt.expectedName {
|
||||
found = true
|
||||
break
|
||||
@@ -118,23 +208,41 @@ func TestGetFileMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test for UpdateFileMetadata
|
||||
func TestUpdateFileMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
setupMutateTestIndex(t)
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_mutate", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
info := &iteminfo.FileInfo{
|
||||
Path: "/testpath/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
ModTime: time.Now(),
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "anotherfile.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt", ModTime: time.Now()}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "anotherfile.txt", ModTime: time.Now()}},
|
||||
},
|
||||
}
|
||||
|
||||
index := &Index{
|
||||
Directories: map[string]*iteminfo.FileInfo{
|
||||
"/testpath/": info,
|
||||
Source: settings.Source{
|
||||
Name: "test_mutate",
|
||||
Path: "/mock/path",
|
||||
},
|
||||
db: indexDB,
|
||||
mock: true,
|
||||
}
|
||||
|
||||
success := index.UpdateMetadata(info)
|
||||
success := index.UpdateMetadata(info, nil) // nil scanner for test
|
||||
if !success {
|
||||
t.Fatalf("expected UpdateFileMetadata to succeed")
|
||||
}
|
||||
@@ -145,90 +253,71 @@ func TestUpdateFileMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test for GetDirMetadata
|
||||
func TestGetDirMetadata(t *testing.T) {
|
||||
idx := setupMutateTestIndex(t)
|
||||
t.Parallel()
|
||||
_, exists := testIndex.GetReducedMetadata("/testpath", true)
|
||||
|
||||
_, exists := idx.GetReducedMetadata("/testpath", true)
|
||||
if !exists {
|
||||
t.Fatalf("expected GetDirMetadata to return initialized metadata map")
|
||||
}
|
||||
|
||||
_, exists = testIndex.GetReducedMetadata("/nonexistent", true)
|
||||
_, exists = idx.GetReducedMetadata("/nonexistent", true)
|
||||
if exists {
|
||||
t.Fatalf("expected GetDirMetadata to return false for nonexistent directory")
|
||||
}
|
||||
}
|
||||
|
||||
// Test for SetDirectoryInfo
|
||||
func TestSetDirectoryInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
setupMutateTestIndex(t)
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_mutate", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
index := &Index{
|
||||
Directories: map[string]*iteminfo.FileInfo{
|
||||
"/testpath/": {
|
||||
Path: "/testpath/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "anotherfile.txt"}},
|
||||
},
|
||||
},
|
||||
Source: settings.Source{
|
||||
Name: "test_mutate",
|
||||
Path: "/mock/path",
|
||||
},
|
||||
db: indexDB,
|
||||
mock: true,
|
||||
}
|
||||
|
||||
// Insert initial testpath directory
|
||||
testpath := &iteminfo.FileInfo{
|
||||
Path: "/testpath/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
ModTime: time.Now(),
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt", ModTime: time.Now()}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "anotherfile.txt", ModTime: time.Now()}},
|
||||
},
|
||||
}
|
||||
_ = index.db.InsertItem("test_mutate", "/testpath/", testpath)
|
||||
|
||||
dir := &iteminfo.FileInfo{
|
||||
Path: "/newPath/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "newPath",
|
||||
Type: "directory",
|
||||
Name: "newPath",
|
||||
Type: "directory",
|
||||
ModTime: time.Now(),
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt", ModTime: time.Now()}},
|
||||
},
|
||||
}
|
||||
index.UpdateMetadata(dir)
|
||||
storedDir, exists := index.Directories["/newPath/"]
|
||||
index.UpdateMetadata(dir, nil) // nil scanner for test
|
||||
storedDir, exists := index.GetMetadataInfo("/newPath/", true, false)
|
||||
if !exists || storedDir.Files[0].Name != "testfile.txt" {
|
||||
t.Fatalf("expected SetDirectoryInfo to store directory info correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
testIndex = Index{
|
||||
ReducedIndex: ReducedIndex{
|
||||
NumFiles: 10,
|
||||
NumDirs: 5,
|
||||
},
|
||||
Source: settings.Source{
|
||||
Path: "/",
|
||||
Name: "test",
|
||||
},
|
||||
|
||||
Directories: map[string]*iteminfo.FileInfo{
|
||||
"/testpath/": {
|
||||
Path: "/testpath/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt", Size: 100}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "anotherfile.txt", Size: 100}},
|
||||
},
|
||||
},
|
||||
"/anotherpath/": {
|
||||
Path: "/anotherpath/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "anotherpath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "afile.txt", Size: 100}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "directory", Type: "directory", Size: 100},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
+212
-108
@@ -1,7 +1,6 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var SearchResultsCache = cache.NewCache[[]string](15 * time.Second)
|
||||
@@ -25,117 +25,108 @@ type SearchResult struct {
|
||||
Size int64 `json:"size"`
|
||||
Modified string `json:"modified,omitempty"`
|
||||
HasPreview bool `json:"hasPreview"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func (idx *Index) Search(search string, scope string, sourceSession string, largest bool, limit int) []*SearchResult {
|
||||
// Ensure scope has consistent trailing slash for directory matching
|
||||
if scope != "" && !strings.HasSuffix(scope, "/") {
|
||||
scope = scope + "/"
|
||||
}
|
||||
originalScope := scope // Preserve original scope for largest mode exclusion check
|
||||
if search == "" {
|
||||
scope = ""
|
||||
}
|
||||
scope = utils.AddTrailingSlashIfNotExists(scope)
|
||||
|
||||
runningHash := utils.InsecureRandomIdentifier(4)
|
||||
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
|
||||
searchOptions := iteminfo.ParseSearch(search)
|
||||
results := make(map[string]*SearchResult, 0)
|
||||
count := 0
|
||||
var directories []string
|
||||
cachedDirs, ok := SearchResultsCache.Get(idx.Path + scope)
|
||||
if ok {
|
||||
directories = cachedDirs
|
||||
} else {
|
||||
directories = idx.getDirsInScope(scope)
|
||||
SearchResultsCache.Set(idx.Path+scope, directories)
|
||||
}
|
||||
|
||||
// When largest=true and no search terms, ensure we run at least once
|
||||
if largest && len(searchOptions.Terms) == 0 {
|
||||
searchOptions.Terms = []string{""}
|
||||
}
|
||||
for _, searchTerm := range searchOptions.Terms {
|
||||
if searchTerm == "" && !largest {
|
||||
continue
|
||||
|
||||
rows, err := idx.db.SearchItems(idx.Name, scope, largest)
|
||||
if err != nil {
|
||||
return []*SearchResult{}
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
// Check for cancellation
|
||||
value, found := sessionInProgress.Load(sourceSession)
|
||||
if !found || value != runningHash {
|
||||
return []*SearchResult{}
|
||||
}
|
||||
|
||||
if limit > 0 && count >= limit {
|
||||
break
|
||||
}
|
||||
idx.mu.Lock()
|
||||
for _, dirName := range directories {
|
||||
dir, found := idx.Directories[dirName]
|
||||
if !found {
|
||||
|
||||
var path string
|
||||
var name string
|
||||
var size int64
|
||||
var modTime int64
|
||||
var mimeType string
|
||||
var isDir bool
|
||||
var hasPreview bool
|
||||
|
||||
if err := rows.Scan(&path, &name, &size, &modTime, &mimeType, &isDir, &hasPreview); err != nil {
|
||||
logger.Errorf("Failed to scan search result row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create ItemInfo for matching
|
||||
item := iteminfo.ItemInfo{
|
||||
Name: name,
|
||||
Size: size,
|
||||
ModTime: time.Unix(modTime, 0),
|
||||
Type: mimeType,
|
||||
HasPreview: hasPreview,
|
||||
}
|
||||
|
||||
// Check against all search terms
|
||||
for _, searchTerm := range searchOptions.Terms {
|
||||
if searchTerm == "" && !largest {
|
||||
continue
|
||||
}
|
||||
if limit > 0 && count >= limit {
|
||||
|
||||
var matches bool
|
||||
if largest {
|
||||
// When largest=true, check size and type conditions, skip name matching
|
||||
largerThan := int64(searchOptions.LargerThan) * 1024 * 1024
|
||||
sizeMatches := largerThan == 0 || item.Size > largerThan
|
||||
// Check if directories should be excluded (when type:file is specified)
|
||||
dirCondition, hasDirCondition := searchOptions.Conditions["dir"]
|
||||
// For directories: match if dir condition is explicitly true
|
||||
// For files: match if no dir condition, or if dir condition is false
|
||||
var typeMatches bool
|
||||
if isDir {
|
||||
typeMatches = hasDirCondition && dirCondition
|
||||
} else {
|
||||
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
|
||||
}
|
||||
matches = sizeMatches && typeMatches
|
||||
} else {
|
||||
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
|
||||
}
|
||||
|
||||
if matches {
|
||||
// Determine type string for response
|
||||
resType := mimeType
|
||||
if isDir {
|
||||
resType = "directory"
|
||||
}
|
||||
|
||||
results[path] = &SearchResult{
|
||||
Path: path,
|
||||
Type: resType,
|
||||
Size: size,
|
||||
Modified: item.ModTime.Format(time.RFC3339),
|
||||
HasPreview: hasPreview,
|
||||
Source: idx.Name,
|
||||
}
|
||||
count++
|
||||
// Break inner loop (terms) if matched, move to next row
|
||||
break
|
||||
}
|
||||
// Skip the scope directory itself when largest=true (only search sub-items)
|
||||
if !(largest && dirName == originalScope) {
|
||||
reducedDir := iteminfo.ItemInfo{
|
||||
Name: filepath.Base(dirName),
|
||||
Type: "directory",
|
||||
Size: dir.Size,
|
||||
}
|
||||
var matches bool
|
||||
if largest {
|
||||
// When largest=true, check size and type conditions, skip name matching
|
||||
largerThan := int64(searchOptions.LargerThan) * 1024 * 1024
|
||||
sizeMatches := largerThan == 0 || reducedDir.Size > largerThan
|
||||
// Check if directories should be excluded (when type:file is specified)
|
||||
dirCondition, hasDirCondition := searchOptions.Conditions["dir"]
|
||||
typeMatches := !hasDirCondition || (hasDirCondition && dirCondition)
|
||||
matches = sizeMatches && typeMatches
|
||||
} else {
|
||||
matches = reducedDir.ContainsSearchTerm(searchTerm, searchOptions)
|
||||
}
|
||||
if matches {
|
||||
results[dirName] = &SearchResult{
|
||||
Path: dirName,
|
||||
Type: "directory",
|
||||
Size: dir.Size,
|
||||
Modified: dir.ModTime.Format(time.RFC3339),
|
||||
HasPreview: dir.HasPreview,
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
// search files first
|
||||
for _, item := range dir.Files {
|
||||
fullPath := filepath.Join(dirName, item.Name)
|
||||
value, found := sessionInProgress.Load(sourceSession)
|
||||
if !found || value != runningHash {
|
||||
idx.mu.Unlock()
|
||||
return []*SearchResult{}
|
||||
}
|
||||
if limit > 0 && count >= limit {
|
||||
break
|
||||
}
|
||||
var matches bool
|
||||
if largest {
|
||||
// When largest=true, check size and type conditions, skip name matching
|
||||
largerThan := int64(searchOptions.LargerThan) * 1024 * 1024
|
||||
sizeMatches := largerThan == 0 || item.Size > largerThan
|
||||
// Check if only files should be included (when type:file is specified)
|
||||
dirCondition, hasDirCondition := searchOptions.Conditions["dir"]
|
||||
// For files: include if no dir condition, or if dir condition is false (type:file)
|
||||
typeMatches := !hasDirCondition || (hasDirCondition && !dirCondition)
|
||||
matches = sizeMatches && typeMatches
|
||||
} else {
|
||||
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
|
||||
}
|
||||
if matches {
|
||||
results[fullPath] = &SearchResult{
|
||||
Path: fullPath,
|
||||
Type: item.Type,
|
||||
Size: item.Size,
|
||||
Modified: item.ModTime.Format(time.RFC3339),
|
||||
HasPreview: item.HasPreview,
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
|
||||
// Sort keys based on the number of elements in the path after splitting by "/"
|
||||
@@ -152,26 +143,139 @@ func (idx *Index) Search(search string, scope string, sourceSession string, larg
|
||||
return sortedKeys
|
||||
}
|
||||
|
||||
func (idx *Index) getDirsInScope(scope string) []string {
|
||||
newList := []string{}
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
// If scope is empty, return all directories
|
||||
if scope == "" {
|
||||
for k := range idx.Directories {
|
||||
newList = append(newList, k)
|
||||
}
|
||||
return newList
|
||||
// SearchMultiSources searches across multiple sources in a single database query.
|
||||
// sources is a list of source names to search.
|
||||
// sourceScopes is a map from source name to the scope path for that source.
|
||||
// sourceSession is used for cancellation tracking.
|
||||
// largest and limit work the same as in Search.
|
||||
func SearchMultiSources(search string, sources []string, sourceScopes map[string]string, sourceSession string, largest bool, limit int) []*SearchResult {
|
||||
if len(sources) == 0 {
|
||||
return []*SearchResult{}
|
||||
}
|
||||
|
||||
// For non-empty scope, include the scope directory itself and all subdirectories
|
||||
for k := range idx.Directories {
|
||||
// Match the scope directory exactly (k == scope)
|
||||
// OR match subdirectories (k starts with scope)
|
||||
if k == scope || strings.HasPrefix(k, scope) {
|
||||
newList = append(newList, k)
|
||||
// Get the shared database
|
||||
db := GetIndexDB()
|
||||
if db == nil {
|
||||
return []*SearchResult{}
|
||||
}
|
||||
|
||||
// Ensure all scopes have consistent trailing slash
|
||||
normalizedScopes := make(map[string]string)
|
||||
for source, scope := range sourceScopes {
|
||||
normalizedScopes[source] = utils.AddTrailingSlashIfNotExists(scope)
|
||||
}
|
||||
|
||||
runningHash := utils.InsecureRandomIdentifier(4)
|
||||
sessionInProgress.Store(sourceSession, runningHash)
|
||||
searchOptions := iteminfo.ParseSearch(search)
|
||||
results := make(map[string]*SearchResult, 0)
|
||||
count := 0
|
||||
|
||||
// When largest=true and no search terms, ensure we run at least once
|
||||
if largest && len(searchOptions.Terms) == 0 {
|
||||
searchOptions.Terms = []string{""}
|
||||
}
|
||||
|
||||
rows, err := db.SearchItemsMultiSource(sources, normalizedScopes, largest)
|
||||
if err != nil {
|
||||
return []*SearchResult{}
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
// Check for cancellation
|
||||
value, found := sessionInProgress.Load(sourceSession)
|
||||
if !found || value != runningHash {
|
||||
return []*SearchResult{}
|
||||
}
|
||||
|
||||
if limit > 0 && count >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
var source string
|
||||
var path string
|
||||
var name string
|
||||
var size int64
|
||||
var modTime int64
|
||||
var mimeType string
|
||||
var isDir bool
|
||||
var hasPreview bool
|
||||
|
||||
if err := rows.Scan(&source, &path, &name, &size, &modTime, &mimeType, &isDir, &hasPreview); err != nil {
|
||||
logger.Errorf("Failed to scan search result row: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create ItemInfo for matching
|
||||
item := iteminfo.ItemInfo{
|
||||
Name: name,
|
||||
Size: size,
|
||||
ModTime: time.Unix(modTime, 0),
|
||||
Type: mimeType,
|
||||
HasPreview: hasPreview,
|
||||
}
|
||||
|
||||
// Check against all search terms
|
||||
for _, searchTerm := range searchOptions.Terms {
|
||||
if searchTerm == "" && !largest {
|
||||
continue
|
||||
}
|
||||
|
||||
var matches bool
|
||||
if largest {
|
||||
// When largest=true, check size and type conditions, skip name matching
|
||||
largerThan := int64(searchOptions.LargerThan) * 1024 * 1024
|
||||
sizeMatches := largerThan == 0 || item.Size > largerThan
|
||||
// Check if directories should be excluded (when type:file is specified)
|
||||
dirCondition, hasDirCondition := searchOptions.Conditions["dir"]
|
||||
// For directories: match if dir condition is explicitly true
|
||||
// For files: match if no dir condition, or if dir condition is false
|
||||
var typeMatches bool
|
||||
if isDir {
|
||||
typeMatches = hasDirCondition && dirCondition
|
||||
} else {
|
||||
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
|
||||
}
|
||||
matches = sizeMatches && typeMatches
|
||||
} else {
|
||||
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
|
||||
}
|
||||
|
||||
if matches {
|
||||
// Determine type string for response
|
||||
resType := mimeType
|
||||
if isDir {
|
||||
resType = "directory"
|
||||
}
|
||||
|
||||
// Use source+path as key to handle same path in different sources
|
||||
key := source + ":" + path
|
||||
results[key] = &SearchResult{
|
||||
Path: path,
|
||||
Type: resType,
|
||||
Size: size,
|
||||
Modified: item.ModTime.Format(time.RFC3339),
|
||||
HasPreview: hasPreview,
|
||||
Source: source,
|
||||
}
|
||||
count++
|
||||
// Break inner loop (terms) if matched, move to next row
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return newList
|
||||
|
||||
// Sort keys based on the number of elements in the path after splitting by "/"
|
||||
sortedKeys := make([]*SearchResult, 0, len(results))
|
||||
for _, v := range results {
|
||||
sortedKeys = append(sortedKeys, v)
|
||||
}
|
||||
// Sort the strings based on the number of elements after splitting by "/"
|
||||
sort.Slice(sortedKeys, func(i, j int) bool {
|
||||
parts1 := strings.Split(sortedKeys[i].Path, "/")
|
||||
parts2 := strings.Split(sortedKeys[j].Path, "/")
|
||||
return len(parts1) < len(parts2)
|
||||
})
|
||||
return sortedKeys
|
||||
}
|
||||
+334
-91
@@ -1,15 +1,53 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Ensure fileutils permissions are set (needed by NewTempDB)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Create a temporary directory for test database files with proper permissions
|
||||
tmpDir, err := os.MkdirTemp("", "indexing_test_*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Ensure the temp directory has correct permissions
|
||||
if err := os.Chmod(tmpDir, 0755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Set the cache directory to the temporary directory
|
||||
originalCacheDir := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = tmpDir
|
||||
defer func() {
|
||||
settings.Config.Server.CacheDir = originalCacheDir
|
||||
}()
|
||||
|
||||
// Run the tests
|
||||
code := m.Run()
|
||||
|
||||
// Exit with the test result code
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func BenchmarkSearchAllIndexes(b *testing.B) {
|
||||
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true)
|
||||
idx := GetIndex("test")
|
||||
@@ -77,59 +115,172 @@ func TestParseSearch(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSearchWhileIndexing(t *testing.T) {
|
||||
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true)
|
||||
idx := GetIndex("test")
|
||||
|
||||
searchTerms := utils.GenerateRandomSearchTerms(10)
|
||||
for i := 0; i < 5; i++ {
|
||||
go idx.CreateMockData(100, 100) // Creating mock data concurrently
|
||||
for _, term := range searchTerms {
|
||||
go idx.Search(term, "/", "test", false, DefaultSearchResults) // Search concurrently
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_search_while", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
source := &settings.Source{Name: "test", Path: "/srv"}
|
||||
// Initialize ResolvedRules to avoid nil pointer dereference
|
||||
source.Config.ResolvedRules = settings.ResolvedRulesConfig{
|
||||
FileNames: make(map[string]settings.ConditionalRule),
|
||||
FolderNames: make(map[string]settings.ConditionalRule),
|
||||
FilePaths: make(map[string]settings.ConditionalRule),
|
||||
FolderPaths: make(map[string]settings.ConditionalRule),
|
||||
FileEndsWith: make([]settings.ConditionalRule, 0),
|
||||
FolderEndsWith: make([]settings.ConditionalRule, 0),
|
||||
FileStartsWith: make([]settings.ConditionalRule, 0),
|
||||
FolderStartsWith: make([]settings.ConditionalRule, 0),
|
||||
NeverWatchPaths: make(map[string]struct{}),
|
||||
IncludeRootItems: make(map[string]struct{}),
|
||||
IgnoreAllHidden: false,
|
||||
IgnoreAllZeroSizeFolders: false,
|
||||
IgnoreAllSymlinks: false,
|
||||
IndexingDisabled: false,
|
||||
}
|
||||
|
||||
Initialize(source, true)
|
||||
idx := GetIndex("test")
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
searchTerms := utils.GenerateRandomSearchTerms(5)
|
||||
|
||||
// Reduced load: 3 iterations × 10 dirs × 10 files = ~300 items total
|
||||
// This is enough to test concurrency without overwhelming the database
|
||||
for i := 0; i < 3; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
idx.CreateMockData(10, 10) // Reduced from 100×100 to 10×10
|
||||
}()
|
||||
|
||||
for _, term := range searchTerms {
|
||||
wg.Add(1)
|
||||
go func(searchTerm string) {
|
||||
defer wg.Done()
|
||||
idx.Search(searchTerm, "/", "test", false, DefaultSearchResults)
|
||||
}(term)
|
||||
}
|
||||
|
||||
// Small delay to reduce contention spikes
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete before test ends
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSearchIndexes(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_search", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
index := Index{
|
||||
Directories: map[string]*iteminfo.FileInfo{
|
||||
"/": {Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "audio-one.wav", Type: "audio"}}}},
|
||||
"/test/": {Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "audio-one.wav", Type: "audio"}}}},
|
||||
"/test/path/": {Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "file.txt", Type: "text"}}}},
|
||||
"/new/test/": {Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "audio.wav", Type: "audio"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "video.mp4", Type: "video"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "video.MP4", Type: "video"}},
|
||||
}},
|
||||
"/first Dir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "space jam.zip", Size: 100, Type: "archive"}},
|
||||
},
|
||||
},
|
||||
"/new/test/path/": {Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "archive.zip", Type: "archive"}}}},
|
||||
"/firstDir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "archive.zip", Size: 100, Type: "archive"}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "thisIsDir", Type: "directory", Size: 2 * 1024 * 1024},
|
||||
},
|
||||
},
|
||||
"/firstDir/thisIsDir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "hi.txt", Type: "text"}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 2 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
"/new+folder/Pictures/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "consoletest.mp4", Size: 196091904, Type: "video/mp4"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "playwright.gif", Size: 2416640, Type: "image/gif"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "toggle.gif", Size: 65536, Type: "image/gif"}},
|
||||
},
|
||||
Source: settings.Source{
|
||||
Name: "test_search",
|
||||
Path: "/mock/path",
|
||||
},
|
||||
db: indexDB,
|
||||
mock: true,
|
||||
}
|
||||
|
||||
// Insert test data into database
|
||||
now := time.Now()
|
||||
directories := map[string]*iteminfo.FileInfo{
|
||||
"/": {ItemInfo: iteminfo.ItemInfo{Name: "/", Type: "directory"}, Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "audio-one.wav", Type: "audio"}}}},
|
||||
"/test/": {ItemInfo: iteminfo.ItemInfo{Name: "test", Type: "directory"}, Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "audio-one.wav", Type: "audio"}}}},
|
||||
"/test/path/": {ItemInfo: iteminfo.ItemInfo{Name: "path", Type: "directory"}, Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "file.txt", Type: "text"}}}},
|
||||
"/new/": {ItemInfo: iteminfo.ItemInfo{Name: "new", Type: "directory"}},
|
||||
"/new/test/": {ItemInfo: iteminfo.ItemInfo{Name: "test", Type: "directory"}, Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "audio.wav", Type: "audio"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "video.mp4", Type: "video"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "video.MP4", Type: "video"}},
|
||||
}},
|
||||
"/first Dir/": {
|
||||
ItemInfo: iteminfo.ItemInfo{Name: "first Dir", Type: "directory"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "space jam.zip", Size: 100, Type: "archive"}},
|
||||
},
|
||||
},
|
||||
"/new/test/path/": {ItemInfo: iteminfo.ItemInfo{Name: "path", Type: "directory"}, Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "archive.zip", Type: "archive"}}}},
|
||||
"/firstDir/": {
|
||||
ItemInfo: iteminfo.ItemInfo{Name: "firstDir", Type: "directory"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "archive.zip", Size: 100, Type: "archive"}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "thisIsDir", Type: "directory", Size: 2 * 1024 * 1024},
|
||||
},
|
||||
},
|
||||
"/firstDir/thisIsDir/": {
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "thisIsDir",
|
||||
Type: "directory",
|
||||
Size: 2 * 1024 * 1024,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "hi.txt", Type: "text"}},
|
||||
},
|
||||
},
|
||||
"/new+folder/": {ItemInfo: iteminfo.ItemInfo{Name: "new+folder", Type: "directory"}},
|
||||
"/new+folder/Pictures/": {
|
||||
ItemInfo: iteminfo.ItemInfo{Name: "Pictures", Type: "directory"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "consoletest.mp4", Size: 196091904, Type: "video/mp4"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "playwright.gif", Size: 2416640, Type: "image/gif"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "toggle.gif", Size: 65536, Type: "image/gif"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Insert all directories and files into database
|
||||
for path, dirInfo := range directories {
|
||||
dirInfo.Path = path
|
||||
if dirInfo.ModTime.IsZero() {
|
||||
dirInfo.ModTime = now
|
||||
}
|
||||
_ = index.db.InsertItem("test_search", path, dirInfo)
|
||||
|
||||
// Insert folders
|
||||
for _, folder := range dirInfo.Folders {
|
||||
folderPath := strings.TrimSuffix(path, "/") + "/" + folder.Name + "/"
|
||||
folderInfo := &iteminfo.FileInfo{
|
||||
Path: folderPath,
|
||||
ItemInfo: folder,
|
||||
}
|
||||
if folderInfo.ModTime.IsZero() {
|
||||
folderInfo.ModTime = now
|
||||
}
|
||||
// Only insert if not already in the directories map (to avoid duplicates)
|
||||
if _, exists := directories[folderPath]; !exists {
|
||||
_ = index.db.InsertItem("test_search", folderPath, folderInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert files
|
||||
for _, file := range dirInfo.Files {
|
||||
filePath := strings.TrimSuffix(path, "/") + "/" + file.Name
|
||||
fileInfo := &iteminfo.FileInfo{
|
||||
Path: filePath,
|
||||
ItemInfo: file.ItemInfo,
|
||||
}
|
||||
if fileInfo.ModTime.IsZero() {
|
||||
fileInfo.ModTime = now
|
||||
}
|
||||
_ = index.db.InsertItem("test_search", filePath, fileInfo)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -308,33 +459,79 @@ func TestSearchIndexes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSearchLargestModeExcludesRoot(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_search_largest", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
index := Index{
|
||||
Directories: map[string]*iteminfo.FileInfo{
|
||||
"/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "root-file.txt", Type: "text", Size: 100}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 3209322496, // Large root directory size
|
||||
},
|
||||
Source: settings.Source{
|
||||
Name: "test_search_largest",
|
||||
Path: "/mock/path",
|
||||
},
|
||||
db: indexDB,
|
||||
mock: true,
|
||||
}
|
||||
|
||||
// Insert test data into database
|
||||
now := time.Now()
|
||||
directories := map[string]*iteminfo.FileInfo{
|
||||
"/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "root-file.txt", Type: "text", Size: 100}},
|
||||
},
|
||||
"/subdir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "sub-file.txt", Type: "text", Size: 200}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 5 * 1024 * 1024, // 5MB subdirectory
|
||||
},
|
||||
},
|
||||
"/another-dir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "large-file.bin", Type: "binary", Size: 10 * 1024 * 1024}}, // 10MB file
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 10 * 1024 * 1024,
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 3209322496, // Large root directory size
|
||||
},
|
||||
},
|
||||
"/subdir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "sub-file.txt", Type: "text", Size: 200}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 5 * 1024 * 1024, // 5MB subdirectory
|
||||
},
|
||||
},
|
||||
"/another-dir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "large-file.bin", Type: "binary", Size: 10 * 1024 * 1024}}, // 10MB file
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 10 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Insert all directories and files into database
|
||||
for path, dirInfo := range directories {
|
||||
dirInfo.Path = path
|
||||
if dirInfo.ModTime.IsZero() {
|
||||
dirInfo.ModTime = now
|
||||
}
|
||||
if dirInfo.Type == "" {
|
||||
dirInfo.Type = "directory"
|
||||
}
|
||||
_ = index.db.InsertItem("test_search_largest", path, dirInfo)
|
||||
|
||||
// Insert files
|
||||
for _, file := range dirInfo.Files {
|
||||
filePath := strings.TrimSuffix(path, "/") + "/" + file.Name
|
||||
fileInfo := &iteminfo.FileInfo{
|
||||
Path: filePath,
|
||||
ItemInfo: file.ItemInfo,
|
||||
}
|
||||
if fileInfo.ModTime.IsZero() {
|
||||
fileInfo.ModTime = now
|
||||
}
|
||||
if fileInfo.Type == "" {
|
||||
fileInfo.Type = "file"
|
||||
}
|
||||
_ = index.db.InsertItem("test_search_largest", filePath, fileInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that when largest=true and scope="/", the root directory "/" is NOT included
|
||||
@@ -358,33 +555,79 @@ func TestSearchLargestModeExcludesRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSearchLargestModeExcludesScopeDirectory(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_search_scope", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
index := Index{
|
||||
Directories: map[string]*iteminfo.FileInfo{
|
||||
"/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "root-file.txt", Type: "text", Size: 50}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 100,
|
||||
},
|
||||
Source: settings.Source{
|
||||
Name: "test_search_scope",
|
||||
Path: "/mock/path",
|
||||
},
|
||||
db: indexDB,
|
||||
mock: true,
|
||||
}
|
||||
|
||||
// Insert test data into database
|
||||
now := time.Now()
|
||||
directories := map[string]*iteminfo.FileInfo{
|
||||
"/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "root-file.txt", Type: "text", Size: 50}},
|
||||
},
|
||||
"/test/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Type: "text", Size: 100}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 2 * 1024 * 1024, // 2MB directory
|
||||
},
|
||||
},
|
||||
"/test/subdir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Type: "text", Size: 200}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 3 * 1024 * 1024, // 3MB subdirectory
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 100,
|
||||
},
|
||||
},
|
||||
"/test/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Type: "text", Size: 100}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 2 * 1024 * 1024, // 2MB directory
|
||||
},
|
||||
},
|
||||
"/test/subdir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Type: "text", Size: 200}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 3 * 1024 * 1024, // 3MB subdirectory
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Insert all directories and files into database
|
||||
for path, dirInfo := range directories {
|
||||
dirInfo.Path = path
|
||||
if dirInfo.ModTime.IsZero() {
|
||||
dirInfo.ModTime = now
|
||||
}
|
||||
if dirInfo.Type == "" {
|
||||
dirInfo.Type = "directory"
|
||||
}
|
||||
_ = index.db.InsertItem("test_search_scope", path, dirInfo)
|
||||
|
||||
// Insert files
|
||||
for _, file := range dirInfo.Files {
|
||||
filePath := strings.TrimSuffix(path, "/") + "/" + file.Name
|
||||
fileInfo := &iteminfo.FileInfo{
|
||||
Path: filePath,
|
||||
ItemInfo: file.ItemInfo,
|
||||
}
|
||||
if fileInfo.ModTime.IsZero() {
|
||||
fileInfo.ModTime = now
|
||||
}
|
||||
if fileInfo.Type == "" {
|
||||
fileInfo.Type = "file"
|
||||
}
|
||||
_ = index.db.InsertItem("test_search_scope", filePath, fileInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that when largest=true and scope="/test/", the scope directory "/test/" is NOT included
|
||||
|
||||
+32
-19
@@ -4,6 +4,7 @@ package indexing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
@@ -15,13 +16,20 @@ func CheckWindowsHidden(realpath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func getFileDetails(sys any, filePath string) (uint64, uint64, uint64, bool) {
|
||||
// On Unix, we should always be able to get syscall info
|
||||
// First try from file.Sys() if available (fast path)
|
||||
// getFileSizeByMode returns file size based on config mode
|
||||
func getFileSizeByMode(stat *syscall.Stat_t, useLogicalSize bool) uint64 {
|
||||
if useLogicalSize {
|
||||
return uint64(stat.Size)
|
||||
}
|
||||
return uint64(stat.Blocks * 512)
|
||||
}
|
||||
|
||||
func getFileDetails(sys any, filePath string, useLogicalSize bool) (uint64, uint64, uint64, bool) {
|
||||
// If useLogicalSize is true, we still need inode info for hardlink detection
|
||||
// but use logical size instead of allocated size
|
||||
if sys != nil {
|
||||
if stat, ok := sys.(*syscall.Stat_t); ok {
|
||||
// Use allocated size for `du`-like behavior
|
||||
realSize := uint64(stat.Blocks * 512)
|
||||
realSize := getFileSizeByMode(stat, useLogicalSize)
|
||||
return realSize, uint64(stat.Nlink), stat.Ino, true
|
||||
}
|
||||
}
|
||||
@@ -43,21 +51,26 @@ func getFileDetails(sys any, filePath string) (uint64, uint64, uint64, bool) {
|
||||
return 0, 1, 0, false
|
||||
}
|
||||
|
||||
// Use allocated size for `du`-like behavior
|
||||
realSize := uint64(stat.Blocks * 512)
|
||||
realSize := getFileSizeByMode(&stat, useLogicalSize)
|
||||
return realSize, uint64(stat.Nlink), stat.Ino, true
|
||||
}
|
||||
|
||||
// handleFile processes a file and returns its size and whether it should be counted
|
||||
// On Unix, always uses syscall to get allocated size (du-like behavior)
|
||||
func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath string) (size uint64, shouldCountSize bool) {
|
||||
// On Unix, always uses syscall to get size (allocated or logical based on config)
|
||||
// scanner parameter is optional - if nil (API refresh), creates temporary state
|
||||
func (idx *Index) handleFile(file os.FileInfo, indexPath string, realFilePath string, isRoutineScan bool, scanner *Scanner) (size uint64, shouldCountSize bool) {
|
||||
var realSize uint64
|
||||
var nlink uint64
|
||||
var ino uint64
|
||||
canUseSyscall := false
|
||||
|
||||
// Use provided realFilePath if available, otherwise construct it
|
||||
if realFilePath == "" {
|
||||
realFilePath = filepath.Join(idx.Path, indexPath)
|
||||
}
|
||||
|
||||
sys := file.Sys()
|
||||
realSize, nlink, ino, canUseSyscall = getFileDetails(sys, realFilePath)
|
||||
realSize, nlink, ino, canUseSyscall = getFileDetails(sys, realFilePath, idx.Config.UseLogicalSize)
|
||||
|
||||
if !canUseSyscall {
|
||||
logger.Errorf("Failed to get syscall info for file %s on Unix system - file may have been deleted or permission denied. Using file.Size() fallback.", realFilePath)
|
||||
@@ -65,16 +78,16 @@ func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath
|
||||
}
|
||||
|
||||
if nlink > 1 {
|
||||
// It's a hard link
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
if _, exists := idx.processedInodes[ino]; exists {
|
||||
// Already seen, don't count towards global total, or directory total.
|
||||
return realSize, false
|
||||
// It's a hard link - use scanner-specific state
|
||||
if scanner != nil {
|
||||
if _, exists := scanner.processedInodes[ino]; exists {
|
||||
// Already seen in this scan, don't count towards global total, or directory total.
|
||||
return realSize, false
|
||||
}
|
||||
// First time seeing this inode in this scan
|
||||
scanner.processedInodes[ino] = struct{}{}
|
||||
scanner.foundHardLinks[indexPath] = realSize
|
||||
}
|
||||
// First time seeing this inode.
|
||||
idx.processedInodes[ino] = struct{}{}
|
||||
idx.FoundHardLinks[fullCombined] = realSize
|
||||
}
|
||||
return realSize, true // Count size for directory total.
|
||||
}
|
||||
|
||||
@@ -34,12 +34,34 @@ func CheckWindowsHidden(realpath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// getFileSizeByMode returns file size based on config mode
|
||||
// Centralizes the logic for choosing between disk usage and logical size
|
||||
func getFileSizeByMode(logicalSize int64, useLogicalSize bool) uint64 {
|
||||
if useLogicalSize {
|
||||
// Logical size mode: return actual file size
|
||||
return uint64(logicalSize)
|
||||
}
|
||||
|
||||
// Disk usage mode: calculate disk space used
|
||||
// If file is empty, return 0
|
||||
if logicalSize == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// On Windows NTFS, cluster size is typically 4KB
|
||||
// Round up to nearest 4KB cluster
|
||||
const clusterSize = 4096
|
||||
clusters := (logicalSize + clusterSize - 1) / clusterSize
|
||||
return uint64(clusters * clusterSize)
|
||||
}
|
||||
|
||||
// handleFile processes a file and returns its size and whether it should be counted
|
||||
// On Windows, uses file.Size() directly (no syscall support for allocated size)
|
||||
func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath string) (size uint64, shouldCountSize bool) {
|
||||
// On Windows, just use the actual file size
|
||||
realSize := uint64(file.Size())
|
||||
return realSize, true
|
||||
// On Windows, calculates size based on configuration (disk usage or logical size)
|
||||
// scanner parameter is accepted for signature compatibility but not used on Windows (no hardlink tracking)
|
||||
func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath string, isRoutineScan bool, scanner *Scanner) (size uint64, shouldCountSize bool) {
|
||||
// Use centralized size calculation logic
|
||||
size = getFileSizeByMode(file.Size(), idx.Config.UseLogicalSize)
|
||||
return size, true
|
||||
}
|
||||
|
||||
// input should be non-index path.
|
||||
|
||||
@@ -8,14 +8,29 @@ import (
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/image/bmp"
|
||||
"golang.org/x/image/tiff"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Ensure fileutils permissions are set (needed by NewPreviewGenerator)
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Run the tests
|
||||
code := m.Run()
|
||||
|
||||
// Exit with the test result code
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestService_Resize(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
options []Option
|
||||
@@ -452,20 +467,20 @@ func TestService_FormatFromExtension(t *testing.T) {
|
||||
func TestCacheKeyConsistency(t *testing.T) {
|
||||
// Test that folder previews and direct file previews use the same cache key
|
||||
// when the folder preview is based on a child file
|
||||
|
||||
|
||||
// Test direct file preview
|
||||
cacheKey1 := CacheKey("testmd5hash123", "small", 0)
|
||||
|
||||
|
||||
// Test folder preview with same child MD5
|
||||
cacheKey2 := CacheKey("testmd5hash123", "small", 0)
|
||||
|
||||
|
||||
// Both should produce the same cache key
|
||||
require.Equal(t, cacheKey1, cacheKey2, "Folder preview and direct file preview should use the same cache key")
|
||||
|
||||
|
||||
// Test with different seek percentages
|
||||
cacheKey3 := CacheKey("testmd5hash123", "small", 25)
|
||||
cacheKey4 := CacheKey("testmd5hash123", "small", 25)
|
||||
|
||||
|
||||
require.Equal(t, cacheKey3, cacheKey4, "Cache keys should be consistent for same parameters")
|
||||
require.NotEqual(t, cacheKey1, cacheKey3, "Different seek percentages should produce different cache keys")
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh
|
||||
## TEST file used by docker testing containers
|
||||
checkExit() {
|
||||
if [ "$?" -ne 0 ];then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v go &> /dev/null
|
||||
then
|
||||
printf "\n == Running benchmark == \n"
|
||||
go test -bench=. -benchtime=10x -benchmem ./...
|
||||
checkExit
|
||||
else
|
||||
echo "ERROR: unable to perform tests"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
go test -race -v -coverpkg=./... -coverprofile=coverage.cov ./...
|
||||
go tool cover -html=coverage.cov
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/bash
|
||||
for i in $(find $(pwd) -name '*.go');do gofmt -w $i;done
|
||||
+794
-65
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,26 @@ definitions:
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
http.BulkDeleteItem:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
type: object
|
||||
http.BulkDeleteResponse:
|
||||
properties:
|
||||
failed:
|
||||
items:
|
||||
$ref: '#/definitions/http.BulkDeleteItem'
|
||||
type: array
|
||||
succeeded:
|
||||
items:
|
||||
$ref: '#/definitions/http.BulkDeleteItem'
|
||||
type: array
|
||||
type: object
|
||||
http.DirectDownloadResponse:
|
||||
properties:
|
||||
hash:
|
||||
@@ -41,6 +61,46 @@ definitions:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
http.MoveCopyItem:
|
||||
properties:
|
||||
fromPath:
|
||||
type: string
|
||||
fromSource:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
toPath:
|
||||
type: string
|
||||
toSource:
|
||||
type: string
|
||||
type: object
|
||||
http.MoveCopyRequest:
|
||||
properties:
|
||||
action:
|
||||
description: '"copy", "move", or "rename"'
|
||||
type: string
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/http.MoveCopyItem'
|
||||
type: array
|
||||
overwrite:
|
||||
description: Overwrite if destination exists
|
||||
type: boolean
|
||||
rename:
|
||||
description: Auto-rename if destination exists
|
||||
type: boolean
|
||||
type: object
|
||||
http.MoveCopyResponse:
|
||||
properties:
|
||||
failed:
|
||||
items:
|
||||
$ref: '#/definitions/http.MoveCopyItem'
|
||||
type: array
|
||||
succeeded:
|
||||
items:
|
||||
$ref: '#/definitions/http.MoveCopyItem'
|
||||
type: array
|
||||
type: object
|
||||
http.ShareResponse:
|
||||
properties:
|
||||
allowCreate:
|
||||
@@ -60,6 +120,8 @@ definitions:
|
||||
type: array
|
||||
banner:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -70,6 +132,9 @@ definitions:
|
||||
disableFileViewer:
|
||||
description: don't allow viewing files
|
||||
type: boolean
|
||||
disableLoginOption:
|
||||
description: disable login option in share (true = hide, false = show)
|
||||
type: boolean
|
||||
disableShareCard:
|
||||
type: boolean
|
||||
disableSidebar:
|
||||
@@ -94,6 +159,8 @@ definitions:
|
||||
type: boolean
|
||||
favicon:
|
||||
type: string
|
||||
faviconUrl:
|
||||
type: string
|
||||
hasPassword:
|
||||
type: boolean
|
||||
hash:
|
||||
@@ -169,6 +236,48 @@ definitions:
|
||||
size:
|
||||
type: integer
|
||||
type: object
|
||||
http.duplicateResponse:
|
||||
properties:
|
||||
groups:
|
||||
items:
|
||||
$ref: '#/definitions/http.duplicateGroup'
|
||||
type: array
|
||||
incomplete:
|
||||
type: boolean
|
||||
reason:
|
||||
type: string
|
||||
type: object
|
||||
http.fileWatchMetadata:
|
||||
properties:
|
||||
modified:
|
||||
description: Modification time
|
||||
type: string
|
||||
name:
|
||||
description: File name
|
||||
type: string
|
||||
path:
|
||||
description: File path
|
||||
type: string
|
||||
size:
|
||||
description: File size in bytes
|
||||
type: integer
|
||||
type:
|
||||
description: MIME type
|
||||
type: string
|
||||
type: object
|
||||
http.fileWatchResponse:
|
||||
properties:
|
||||
contents:
|
||||
description: Text content for text files
|
||||
type: string
|
||||
isText:
|
||||
description: Whether the file is a text file
|
||||
type: boolean
|
||||
metadata:
|
||||
allOf:
|
||||
- $ref: '#/definitions/http.fileWatchMetadata'
|
||||
description: File metadata for non-text files
|
||||
type: object
|
||||
indexing.IndexStatus:
|
||||
enum:
|
||||
- ready
|
||||
@@ -182,12 +291,13 @@ definitions:
|
||||
indexing.ReducedIndex:
|
||||
properties:
|
||||
complexity:
|
||||
description: '0-10 scale: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex'
|
||||
type: integer
|
||||
fullScanDurationSeconds:
|
||||
type: integer
|
||||
lastIndexedUnixTime:
|
||||
type: integer
|
||||
lastScanned:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
numDeleted:
|
||||
@@ -212,23 +322,28 @@ definitions:
|
||||
indexing.ScannerInfo:
|
||||
properties:
|
||||
complexity:
|
||||
description: '0-10 scale: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex'
|
||||
type: integer
|
||||
currentSchedule:
|
||||
type: integer
|
||||
fullScanTime:
|
||||
fullScanDurationSeconds:
|
||||
type: integer
|
||||
isRoot:
|
||||
type: boolean
|
||||
lastIndexedUnixTime:
|
||||
type: integer
|
||||
lastScanned:
|
||||
type: string
|
||||
numDeleted:
|
||||
type: integer
|
||||
numDirs:
|
||||
type: integer
|
||||
numFiles:
|
||||
type: integer
|
||||
path:
|
||||
type: string
|
||||
quickScanTime:
|
||||
quickScanDurationSeconds:
|
||||
type: integer
|
||||
used:
|
||||
type: integer
|
||||
type: object
|
||||
indexing.SearchResult:
|
||||
@@ -241,6 +356,8 @@ definitions:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
source:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
@@ -378,22 +495,27 @@ definitions:
|
||||
settings.ConditionalFilter:
|
||||
properties:
|
||||
hidden:
|
||||
description: 'deprecated: use ignoreHidden instead to exclude hidden files
|
||||
and folders.'
|
||||
description: 'deprecated: use ignoreHidden instead. eg, FolderPath: "/" and
|
||||
ignoreHidden: true will exclude hidden files and folders under the root
|
||||
folder.'
|
||||
type: boolean
|
||||
ignoreHidden:
|
||||
description: exclude hidden files and folders.
|
||||
description: 'deprecated: use ignoreHidden instead. eg, FolderPath: "/" and
|
||||
ignoreHidden: true will exclude hidden files and folders under the root
|
||||
folder.'
|
||||
type: boolean
|
||||
ignoreZeroSizeFolders:
|
||||
description: ignore folders with 0 size
|
||||
description: 'deprecated: use ignoreZeroSizeFolders instead. eg, FolderPath:
|
||||
"/" and ignoreZeroSizeFolders: true will ignore folders with 0 size under
|
||||
the root folder.'
|
||||
type: boolean
|
||||
rules:
|
||||
description: list of item rules to apply to specific paths
|
||||
items:
|
||||
$ref: '#/definitions/settings.ConditionalIndexConfig'
|
||||
$ref: '#/definitions/settings.ConditionalRule'
|
||||
type: array
|
||||
type: object
|
||||
settings.ConditionalIndexConfig:
|
||||
settings.ConditionalRule:
|
||||
properties:
|
||||
fileEndsWith:
|
||||
description: (global) exclude files that end with these suffixes. Eg. ".jpg"
|
||||
@@ -435,6 +557,15 @@ definitions:
|
||||
description: (global) exclude folders that start with these prefixes. Eg.
|
||||
"archive-" or "backup-"
|
||||
type: string
|
||||
ignoreHidden:
|
||||
description: Excludes only hidden files and folders
|
||||
type: boolean
|
||||
ignoreSymlinks:
|
||||
description: Excludes symbolic links
|
||||
type: boolean
|
||||
ignoreZeroSizeFolders:
|
||||
description: Excludes only folders with 0 size
|
||||
type: boolean
|
||||
includeRootItem:
|
||||
description: include only these items at root folder level
|
||||
type: string
|
||||
@@ -527,6 +658,23 @@ definitions:
|
||||
styling:
|
||||
$ref: '#/definitions/settings.StylingConfig'
|
||||
type: object
|
||||
settings.IndexSqlConfig:
|
||||
properties:
|
||||
batchSize:
|
||||
description: number of items to batch in a single transaction, typically 500-5000.
|
||||
higher = faster but could use more memory.
|
||||
type: integer
|
||||
cacheSizeMB:
|
||||
description: size of the SQLite cache in MB
|
||||
type: integer
|
||||
disableReuse:
|
||||
description: enable to always create a new indexing database on startup.
|
||||
type: boolean
|
||||
walMode:
|
||||
description: enable the more complex WAL journaling mode. Slower, more memory
|
||||
usage, but better for deployments with constant user activity.
|
||||
type: boolean
|
||||
type: object
|
||||
settings.Integrations:
|
||||
properties:
|
||||
media:
|
||||
@@ -706,7 +854,7 @@ definitions:
|
||||
cacheDirCleanup:
|
||||
description: 'whether to automatically cleanup the cache directory. Note:
|
||||
docker must also mount a persistent volume to persist the cache (default:
|
||||
true)'
|
||||
false)'
|
||||
type: boolean
|
||||
database:
|
||||
description: path to the database file
|
||||
@@ -730,6 +878,10 @@ definitions:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.Filesystem'
|
||||
description: filesystem settings
|
||||
indexSqlConfig:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.IndexSqlConfig'
|
||||
description: Index database SQL configuration
|
||||
internalUrl:
|
||||
description: used by integrations if set, this is the base domain that an
|
||||
integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
@@ -803,8 +955,7 @@ definitions:
|
||||
conditionals:
|
||||
allOf:
|
||||
- $ref: '#/definitions/settings.ConditionalFilter'
|
||||
description: conditional rules to apply when indexing to include/exclude certain
|
||||
items
|
||||
description: 'deprecated: use source.rules instead'
|
||||
createUserDir:
|
||||
description: create a user directory for each user under defaultUserScope
|
||||
+ username
|
||||
@@ -819,20 +970,30 @@ definitions:
|
||||
description: deny access unless an "allow" access rule was specifically created.
|
||||
type: boolean
|
||||
disableIndexing:
|
||||
description: '(optional) not recommended: disable the indexing of this source'
|
||||
description: 'deprecated: use indexingDisabled instead to disable the indexing
|
||||
of this source'
|
||||
type: boolean
|
||||
disabled:
|
||||
description: disable the source, this is useful so you don't need to remove
|
||||
it from the config file
|
||||
type: boolean
|
||||
indexingIntervalMinutes:
|
||||
description: '(optional) not recommended: manual overide interval in minutes
|
||||
to re-index the source'
|
||||
description: 'deprecated: create a rule with indexingIntervalMinutes to set
|
||||
the indexing interval for this source'
|
||||
type: integer
|
||||
private:
|
||||
description: designate as source as private -- currently just means no sharing
|
||||
permitted.
|
||||
type: boolean
|
||||
rules:
|
||||
description: list of item rules to apply to specific paths
|
||||
items:
|
||||
$ref: '#/definitions/settings.ConditionalRule'
|
||||
type: array
|
||||
useLogicalSize:
|
||||
description: calculate sizes based on logical size instead of disk utilization
|
||||
(du -sh), folders will be 0 bytes when empty.
|
||||
type: boolean
|
||||
type: object
|
||||
settings.StylingConfig:
|
||||
properties:
|
||||
@@ -1034,6 +1195,8 @@ definitions:
|
||||
type: array
|
||||
banner:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -1044,6 +1207,9 @@ definitions:
|
||||
disableFileViewer:
|
||||
description: don't allow viewing files
|
||||
type: boolean
|
||||
disableLoginOption:
|
||||
description: disable login option in share (true = hide, false = show)
|
||||
type: boolean
|
||||
disableShareCard:
|
||||
type: boolean
|
||||
disableSidebar:
|
||||
@@ -1064,6 +1230,8 @@ definitions:
|
||||
type: boolean
|
||||
favicon:
|
||||
type: string
|
||||
faviconUrl:
|
||||
type: string
|
||||
hasPassword:
|
||||
type: boolean
|
||||
hideNavButtons:
|
||||
@@ -1124,6 +1292,8 @@ definitions:
|
||||
type: array
|
||||
banner:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -1134,6 +1304,9 @@ definitions:
|
||||
disableFileViewer:
|
||||
description: don't allow viewing files
|
||||
type: boolean
|
||||
disableLoginOption:
|
||||
description: disable login option in share (true = hide, false = show)
|
||||
type: boolean
|
||||
disableShareCard:
|
||||
type: boolean
|
||||
disableSidebar:
|
||||
@@ -1156,6 +1329,8 @@ definitions:
|
||||
type: boolean
|
||||
favicon:
|
||||
type: string
|
||||
faviconUrl:
|
||||
type: string
|
||||
hasPassword:
|
||||
type: boolean
|
||||
hash:
|
||||
@@ -1222,6 +1397,8 @@ definitions:
|
||||
type: array
|
||||
banner:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -1232,6 +1409,9 @@ definitions:
|
||||
disableFileViewer:
|
||||
description: don't allow viewing files
|
||||
type: boolean
|
||||
disableLoginOption:
|
||||
description: disable login option in share (true = hide, false = show)
|
||||
type: boolean
|
||||
disableShareCard:
|
||||
type: boolean
|
||||
disableSidebar:
|
||||
@@ -1256,6 +1436,8 @@ definitions:
|
||||
type: boolean
|
||||
favicon:
|
||||
type: string
|
||||
faviconUrl:
|
||||
type: string
|
||||
hasPassword:
|
||||
type: boolean
|
||||
hash:
|
||||
@@ -1331,6 +1513,8 @@ definitions:
|
||||
properties:
|
||||
clearAll:
|
||||
type: boolean
|
||||
downloadChunkSizeMb:
|
||||
type: integer
|
||||
maxConcurrentUpload:
|
||||
type: integer
|
||||
uploadChunkSizeMb:
|
||||
@@ -2234,7 +2418,12 @@ paths:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Finds duplicate files based on size and fuzzy filename matching
|
||||
description: 'Finds duplicate files using multi-stage filtering: size → type
|
||||
→ fuzzy filename → progressive checksums. Files must match on size, MIME type,
|
||||
and have 50%+ filename similarity before checksum verification. Large fuzzy
|
||||
groups (>10 files) are skipped to avoid false positives. Checksums use 2-pass
|
||||
progressive verification (header → middle) for accuracy while minimizing disk
|
||||
I/O (~16KB read per file).'
|
||||
parameters:
|
||||
- description: Source name for the desired source
|
||||
in: query
|
||||
@@ -2253,17 +2442,23 @@ paths:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of duplicate file groups
|
||||
description: List of duplicate file groups with metadata. Response includes
|
||||
'incomplete' flag if processing stopped early due to resource limits.
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/http.duplicateGroup'
|
||||
type: array
|
||||
$ref: '#/definitions/http.duplicateResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"503":
|
||||
description: Service Unavailable (indexing in progress or another search
|
||||
running)
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Find Duplicate Files
|
||||
tags:
|
||||
- Duplicates
|
||||
@@ -2428,10 +2623,14 @@ paths:
|
||||
**Filename Encoding:**
|
||||
- The Content-Disposition header will always include both:
|
||||
1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
|
||||
2. `filename*=utf-8”...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
|
||||
2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
|
||||
parameters:
|
||||
- description: a list of files in the following format 'source::filename' and
|
||||
separated by '||' with additional items in the list. (required)
|
||||
- description: Source name for the files (required)
|
||||
in: query
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: Comma-separated list of file paths (required)
|
||||
in: query
|
||||
name: files
|
||||
required: true
|
||||
@@ -2567,6 +2766,10 @@ paths:
|
||||
in: query
|
||||
name: content
|
||||
type: string
|
||||
- description: Extract audio/video metadata if true
|
||||
in: query
|
||||
name: metadata
|
||||
type: string
|
||||
- description: Optional checksum validation
|
||||
in: query
|
||||
name: checksum
|
||||
@@ -2596,36 +2799,32 @@ paths:
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Moves or renames a resource to a new destination.
|
||||
description: Performs move, copy, or rename operations on multiple resources.
|
||||
All operations are performed atomically.
|
||||
parameters:
|
||||
- description: Path from resource in <source_name>::<index_path> format
|
||||
in: query
|
||||
name: from
|
||||
- description: Move/copy request with items and action
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
type: string
|
||||
- description: Destination path for the resource
|
||||
in: query
|
||||
name: destination
|
||||
required: true
|
||||
type: string
|
||||
- description: Action to perform (copy, rename)
|
||||
in: query
|
||||
name: action
|
||||
required: true
|
||||
type: string
|
||||
- description: Overwrite if destination exists
|
||||
in: query
|
||||
name: overwrite
|
||||
type: boolean
|
||||
- description: Rename if destination exists
|
||||
in: query
|
||||
name: rename
|
||||
type: boolean
|
||||
schema:
|
||||
$ref: '#/definitions/http.MoveCopyRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Resource moved/renamed successfully
|
||||
description: All operations completed successfully
|
||||
schema:
|
||||
$ref: '#/definitions/http.MoveCopyResponse'
|
||||
"207":
|
||||
description: Partial success - some operations succeeded, some failed
|
||||
schema:
|
||||
$ref: '#/definitions/http.MoveCopyResponse'
|
||||
"400":
|
||||
description: Bad request - invalid JSON or parameters
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
@@ -2638,19 +2837,11 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"409":
|
||||
description: Conflict - Destination exists
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
description: All operations failed
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Patch resource (move/rename)
|
||||
$ref: '#/definitions/http.MoveCopyResponse'
|
||||
summary: Move, copy, or rename resources
|
||||
tags:
|
||||
- Resources
|
||||
post:
|
||||
@@ -2759,25 +2950,80 @@ paths:
|
||||
summary: Update a file resource
|
||||
tags:
|
||||
- Resources
|
||||
/api/resources/bulk/delete:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Deletes multiple resources specified in the request body. Returns
|
||||
a list of succeeded and failed deletions.
|
||||
parameters:
|
||||
- description: Array of items to delete, each with source and path
|
||||
in: body
|
||||
name: items
|
||||
required: true
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/http.BulkDeleteItem'
|
||||
type: array
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: All resources deleted successfully
|
||||
schema:
|
||||
$ref: '#/definitions/http.BulkDeleteResponse'
|
||||
"207":
|
||||
description: Partial success - some resources deleted, some failed
|
||||
schema:
|
||||
$ref: '#/definitions/http.BulkDeleteResponse'
|
||||
"400":
|
||||
description: Bad request - invalid JSON or empty items array
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error - all deletions failed
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Bulk delete resources
|
||||
tags:
|
||||
- Resources
|
||||
/api/search:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Searches for files matching the provided query. Returns file paths
|
||||
and metadata based on the user's session and scope.
|
||||
and metadata based on the user's session and scope. Supports searching across
|
||||
multiple sources when using the 'sources' parameter.
|
||||
parameters:
|
||||
- description: Search query
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
type: string
|
||||
- description: Source name for the desired source
|
||||
- description: Source name for the desired source (deprecated, use 'sources'
|
||||
instead)
|
||||
in: query
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: Comma-separated list of source names to search across multiple
|
||||
sources. When multiple sources are specified, scope is always the user's
|
||||
scope for each source.
|
||||
in: query
|
||||
name: sources
|
||||
type: string
|
||||
- description: path within user scope to search, for example '/first/second'
|
||||
to search within the second directory only
|
||||
to search within the second directory only. Ignored when multiple sources
|
||||
are specified.
|
||||
in: query
|
||||
name: scope
|
||||
type: string
|
||||
@@ -2789,7 +3035,7 @@ paths:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of search results
|
||||
description: List of search results with source field populated
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/indexing.SearchResult'
|
||||
@@ -3069,6 +3315,111 @@ paths:
|
||||
summary: List share links
|
||||
tags:
|
||||
- Shares
|
||||
/api/tools/watch:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns the last N lines of a file
|
||||
parameters:
|
||||
- description: Path to the file
|
||||
in: query
|
||||
name: path
|
||||
required: true
|
||||
type: string
|
||||
- description: Source name
|
||||
in: query
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: 'Number of lines to read (default: 10, max: 50)'
|
||||
in: query
|
||||
name: lines
|
||||
type: integer
|
||||
- description: Return minimal response for latency checking
|
||||
in: query
|
||||
name: latencyCheck
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/http.fileWatchResponse'
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Permission denied
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: File not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Watch a file
|
||||
tags:
|
||||
- Tools
|
||||
/api/tools/watch/sse:
|
||||
get:
|
||||
description: Establishes an SSE connection to receive periodic file updates
|
||||
parameters:
|
||||
- description: Path to the file
|
||||
in: query
|
||||
name: path
|
||||
required: true
|
||||
type: string
|
||||
- description: Source name
|
||||
in: query
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: 'Number of lines to read (default: 10, max: 50)'
|
||||
in: query
|
||||
name: lines
|
||||
type: integer
|
||||
- description: Update interval in seconds (1, 2, 5, 10, 15, or 30, requires
|
||||
realtime permission for SSE)
|
||||
in: query
|
||||
name: interval
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: SSE stream
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Permission denied
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: File not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Watch a file via SSE
|
||||
tags:
|
||||
- Tools
|
||||
/api/users:
|
||||
delete:
|
||||
consumes:
|
||||
@@ -3299,8 +3650,7 @@ paths:
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: 'Files to download in format: ''source::path||source::path''.
|
||||
Example: ''/file1||/folder/file2'''
|
||||
- description: 'Comma-separated list of file paths. Example: ''file1.txt,folder/file2.txt'''
|
||||
in: query
|
||||
name: files
|
||||
required: true
|
||||
@@ -3373,6 +3723,14 @@ paths:
|
||||
in: query
|
||||
name: path
|
||||
type: string
|
||||
- description: Include file content if true
|
||||
in: query
|
||||
name: content
|
||||
type: string
|
||||
- description: Extract audio/video metadata if true
|
||||
in: query
|
||||
name: metadata
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -3407,6 +3765,59 @@ paths:
|
||||
summary: Get file/directory information from a public share
|
||||
tags:
|
||||
- Public Shares
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Performs move, copy, or rename operations on multiple resources
|
||||
within a public share. All operations are performed atomically.
|
||||
parameters:
|
||||
- description: Share hash for authentication
|
||||
in: query
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: Move/copy request with items and action
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.MoveCopyRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: All operations completed successfully
|
||||
schema:
|
||||
$ref: '#/definitions/http.MoveCopyResponse'
|
||||
"207":
|
||||
description: Partial success - some operations succeeded, some failed
|
||||
schema:
|
||||
$ref: '#/definitions/http.MoveCopyResponse'
|
||||
"400":
|
||||
description: Bad request - invalid JSON or parameters
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden - modify not allowed for this share
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Share or resource not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.MoveCopyResponse'
|
||||
summary: Move, copy, or rename resources in a public share
|
||||
tags:
|
||||
- Public Shares
|
||||
post:
|
||||
consumes:
|
||||
- multipart/form-data
|
||||
@@ -3543,6 +3954,103 @@ paths:
|
||||
summary: Update a file in a public share
|
||||
tags:
|
||||
- Public Shares
|
||||
/public/api/resources/bulk/delete:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Deletes multiple resources specified in the request body. Returns
|
||||
a list of succeeded and failed deletions.
|
||||
parameters:
|
||||
- description: Share hash for authentication
|
||||
in: query
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: Array of items to delete, each with source and path
|
||||
in: body
|
||||
name: items
|
||||
required: true
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/http.BulkDeleteItem'
|
||||
type: array
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: All resources deleted successfully
|
||||
schema:
|
||||
$ref: '#/definitions/http.BulkDeleteResponse'
|
||||
"207":
|
||||
description: Partial success - some resources deleted, some failed
|
||||
schema:
|
||||
$ref: '#/definitions/http.BulkDeleteResponse'
|
||||
"400":
|
||||
description: Bad request - invalid JSON or empty items array
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden - delete not allowed for this share
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error - all deletions failed
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Bulk delete resources from public share
|
||||
tags:
|
||||
- Public Shares
|
||||
/public/api/share/image:
|
||||
get:
|
||||
description: Serves the banner or favicon file for a share
|
||||
parameters:
|
||||
- description: Share hash
|
||||
in: query
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: Request banner file
|
||||
in: query
|
||||
name: banner
|
||||
type: boolean
|
||||
- description: Request favicon file
|
||||
in: query
|
||||
name: favicon
|
||||
type: boolean
|
||||
produces:
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"200":
|
||||
description: Image file content
|
||||
schema:
|
||||
type: file
|
||||
"400":
|
||||
description: Invalid request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Permission denied
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Asset not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get share image (banner or favicon)
|
||||
tags:
|
||||
- Public Shares
|
||||
/public/api/shareinfo:
|
||||
get:
|
||||
consumes:
|
||||
|
||||
Generated
+4
-18
@@ -9,7 +9,6 @@
|
||||
"version": "3.0.0",
|
||||
"dependencies": {
|
||||
"@onlyoffice/document-editor-vue": "^1.4.0",
|
||||
"@skjnldsv/vue-plyr": "^7.5.0",
|
||||
"ace-builds": "^1.24.2",
|
||||
"axios": "^1.7.9",
|
||||
"clipboard": "^2.0.4",
|
||||
@@ -22,6 +21,7 @@
|
||||
"mammoth": "^1.9.1",
|
||||
"marked": "^15.0.6",
|
||||
"normalize.css": "^8.0.1",
|
||||
"plyr": "^3.8.4",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
"srt-support-for-html5-videos": "^2.6.11",
|
||||
"vue": "^3.4.21",
|
||||
@@ -1540,20 +1540,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@skjnldsv/vue-plyr": {
|
||||
"version": "7.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@skjnldsv/vue-plyr/-/vue-plyr-7.5.0.tgz",
|
||||
"integrity": "sha512-JmbKPe8Eh0yTP+7w4VYeqGhrUqDKM4qSsCn37CbADAvvwMvceBwYC0IBXil0RBtyzStxwXPtOj1oI//lQ5DeTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"plyr": "^3.7.8",
|
||||
"vue": "^3.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0",
|
||||
"npm": "^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -5524,9 +5510,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/plyr": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.3.tgz",
|
||||
"integrity": "sha512-0+iI5uw0WRvtKBpgPCkmQQv7ucHVQKTEo6UFJjgJ8cy/JZhy0dQqshHQVitHXV6l2O3MzhgnuvQ95VSkWcWeSw==",
|
||||
"version": "3.8.4",
|
||||
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz",
|
||||
"integrity": "sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.45.1",
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@onlyoffice/document-editor-vue": "^1.4.0",
|
||||
"@skjnldsv/vue-plyr": "^7.5.0",
|
||||
"ace-builds": "^1.24.2",
|
||||
"axios": "^1.7.9",
|
||||
"clipboard": "^2.0.4",
|
||||
@@ -34,6 +33,7 @@
|
||||
"mammoth": "^1.9.1",
|
||||
"marked": "^15.0.6",
|
||||
"normalize.css": "^8.0.1",
|
||||
"plyr": "^3.8.4",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
"srt-support-for-html5-videos": "^2.6.11",
|
||||
"vue": "^3.4.21",
|
||||
|
||||
@@ -26,34 +26,24 @@ server:
|
||||
denyByDefault: false # deny access unless an "allow" access rule was specifically created.
|
||||
private: false # designate as source as private -- currently just means no sharing permitted.
|
||||
disabled: false # disable the source, this is useful so you don't need to remove it from the config file
|
||||
indexingIntervalMinutes: 0 # (optional) not recommended: manual overide interval in minutes to re-index the source
|
||||
disableIndexing: false # (optional) not recommended: disable the indexing of this source
|
||||
conditionals: # conditional rules to apply when indexing to include/exclude certain items
|
||||
ignoreHidden: false # exclude hidden files and folders.
|
||||
ignoreZeroSizeFolders: false # ignore folders with 0 size
|
||||
rules: # list of item rules to apply to specific paths
|
||||
- neverWatchPath: "" # index the folder in the first pass to get included in search, but never re-indexed.
|
||||
includeRootItem: "" # include only these items at root folder level
|
||||
fileStartsWith: "" # (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
|
||||
folderStartsWith: "" # (global) exclude folders that start with these prefixes. Eg. "archive-" or "backup-"
|
||||
fileEndsWith: "" # (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
|
||||
folderEndsWith: "" # (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
|
||||
folderPath: "" # (global) exclude folders that match this path. Eg. "/path/to/folder" or "/path/to/folder/subfolder"
|
||||
filePath: "" # (global) exclude files that match this path. Eg. "/path/to/file.txt" or "/path/to/file.txt/subfile.txt"
|
||||
fileName: "" # (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
|
||||
folderName: "" # (global) exclude folders that match these names. Eg. "folder" or "subfolder"
|
||||
viewable: false # Enable viewing in UI but exclude from indexing
|
||||
rules: # list of item rules to apply to specific paths
|
||||
defaultUserScope: "/" # defaults to root of index "/" should match folders under path
|
||||
defaultEnabled: true # should be added as a default source for new users?
|
||||
createUserDir: false # create a user directory for each user under defaultUserScope + username
|
||||
useLogicalSize: false # calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
|
||||
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
|
||||
cacheDirCleanup: true # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: true)
|
||||
cacheDirCleanup: false # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
maxArchiveSize: 50 # max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
filesystem: # filesystem settings
|
||||
createFilePermission: "644" # Unix permissions like 644, 755, 2755 (default: 644) validate:required,file_permission
|
||||
createDirectoryPermission: "755" # Unix permissions like 755, 2755, 1777 (default: 755) validate:required,file_permission
|
||||
indexSqlConfig: # Index database SQL configuration
|
||||
batchSize: 1000 # number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
|
||||
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.
|
||||
auth:
|
||||
tokenExpirationHours: 2 # time in hours each web UI session token is valid for. Default is 2 hours.
|
||||
methods:
|
||||
@@ -161,6 +151,7 @@ userDefaults:
|
||||
maxConcurrentUpload: 10
|
||||
uploadChunkSizeMb: 10
|
||||
clearAll: false
|
||||
downloadChunkSizeMb: 0
|
||||
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config.
|
||||
showSelectMultiple: false # show select multiple files on desktop
|
||||
|
||||
+28
-93
@@ -30,6 +30,21 @@
|
||||
<meta name="msapplication-TileColor" content="{{ .htmlVars.color }}">
|
||||
|
||||
<meta name="description" content="{{ .htmlVars.description }}">
|
||||
|
||||
<!-- OpenGraph metadata -->
|
||||
<meta property="og:title" content="{{ .htmlVars.title }}">
|
||||
<meta property="og:description" content="{{ .htmlVars.description }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ .htmlVars.url }}">
|
||||
<meta property="og:image" content="{{ .htmlVars.image }}">
|
||||
<meta property="og:image:alt" content="{{ .htmlVars.title }}">
|
||||
|
||||
<!-- Twitter Card metadata -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ .htmlVars.title }}">
|
||||
<meta name="twitter:description" content="{{ .htmlVars.description }}">
|
||||
<meta name="twitter:image" content="{{ .htmlVars.image }}">
|
||||
|
||||
<link rel="canonical" href="https://github.com/gtsteffaniak/filebrowser">
|
||||
<link rel="canonical" href="https://github.com/quantumx-apps">
|
||||
<link rel="canonical" href="https://filebrowserquantum.com">
|
||||
@@ -39,22 +54,30 @@
|
||||
window.globalVars = {{ .globalVars }};
|
||||
var dynamicManifest = {
|
||||
"name": window.globalVars.name || 'FileBrowser Quantum',
|
||||
"short_name": window.globalVars.name || 'FileBrowser',
|
||||
"icons": [
|
||||
{
|
||||
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-256x256.png",
|
||||
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-256x256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
}
|
||||
],
|
||||
"start_url": window.location.origin + "{{ .htmlVars.baseURL }}",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "{{ .htmlVars.color }}"
|
||||
"theme_color": "{{ .htmlVars.color }}",
|
||||
"description": "{{ .htmlVars.description }}"
|
||||
}
|
||||
|
||||
const stringManifest = JSON.stringify(dynamicManifest);
|
||||
@@ -87,102 +110,14 @@
|
||||
--activeWhiteIcon: white;
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
z-index: 9999;
|
||||
transition: .1s ease opacity;
|
||||
-webkit-transition: .1s ease opacity;
|
||||
}
|
||||
|
||||
#loading.done {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#loading .spinner {
|
||||
width: 70px;
|
||||
text-align: center;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
#loading .spinner>div {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: #333;
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
-webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
|
||||
animation: sk-bouncedelay 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
#loading .spinner .bounce1 {
|
||||
-webkit-animation-delay: -0.32s;
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
#loading .spinner .bounce2 {
|
||||
-webkit-animation-delay: -0.16s;
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes sk-bouncedelay {
|
||||
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
-webkit-transform: scale(0)
|
||||
}
|
||||
|
||||
40% {
|
||||
-webkit-transform: scale(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sk-bouncedelay {
|
||||
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
-webkit-transform: scale(1.0);
|
||||
transform: scale(1.0);
|
||||
}
|
||||
}
|
||||
{{ .htmlVars.loadingSpinnersCSS }}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<span class="loader loader--medium"></span>
|
||||
|
||||
{{ if .htmlVars.darkMode -}}
|
||||
<div id="loading" class="dark-mode">
|
||||
<div class="spinner">
|
||||
<div class="bounce1"></div>
|
||||
<div class="bounce2"></div>
|
||||
<div class="bounce3"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div id="loading">
|
||||
<div class="spinner">
|
||||
<div class="bounce1"></div>
|
||||
<div class="bounce2"></div>
|
||||
<div class="bounce3"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* NOTE: This file only contains styles needed by index.html.
|
||||
All other loader styles (base .loader, size variants, modes, etc.)
|
||||
are defined in LoadingSpinner.vue as scoped styles.*/
|
||||
body > .loader {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: .1s ease opacity;
|
||||
pointer-events: none;
|
||||
/* Reset loader styles - spinner will be in pseudo-element */
|
||||
border: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
body > .loader::before {
|
||||
content: '';
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border-top: 3px solid var(--spinner-color, var(--primaryColor));
|
||||
border-right: 3px solid transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
box-sizing: border-box;
|
||||
animation: spinner-rotation var(--animation-duration, 1s) linear infinite;
|
||||
}
|
||||
|
||||
/* Size variants for body > .loader */
|
||||
body > .loader.loader--xsmall::before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
body > .loader.loader--small::before {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
body > .loader.loader--medium::before {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
body > .loader.loader--large::before {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
body > .loader.done {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes spinner-rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@ export default {
|
||||
onMounted(() => {
|
||||
mutations.setLoading("main-app", false);
|
||||
// Query the loading element and remove it from the DOM
|
||||
const loadingDiv = document.getElementById("loading");
|
||||
if (loadingDiv) {
|
||||
loadingDiv.remove();
|
||||
const loadingLoader = document.querySelector("body > .loader");
|
||||
if (loadingLoader) {
|
||||
loadingLoader.remove();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getApiPath } from '@/utils/url.js'
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function get(source, path) {
|
||||
const apiPath = getApiPath('api/access', { source, path })
|
||||
const apiPath = getApiPath('api/access', { source, path }, true)
|
||||
return fetchJSON(apiPath)
|
||||
}
|
||||
/**
|
||||
@@ -15,14 +15,14 @@ export async function get(source, path) {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function getAll(source) {
|
||||
const apiPath = getApiPath('api/access', { source })
|
||||
const apiPath = getApiPath('api/access', { source }, true)
|
||||
return fetchJSON(apiPath)
|
||||
}
|
||||
/**
|
||||
* @returns {Promise<{groups: string[]}>}
|
||||
*/
|
||||
export async function getGroups() {
|
||||
const apiPath = getApiPath('api/access/groups')
|
||||
const apiPath = getApiPath('api/access/groups', {}, true)
|
||||
return fetchJSON(apiPath)
|
||||
}
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ export async function getGroups() {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function add(source, path, body) {
|
||||
const apiPath = getApiPath('api/access', { source, path });
|
||||
const apiPath = getApiPath('api/access', { source, path }, true);
|
||||
return fetchJSON(apiPath, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
@@ -52,7 +52,7 @@ export async function del(source, path, body) {
|
||||
if (cascade) {
|
||||
params.cascade = 'true';
|
||||
}
|
||||
const apiPath = getApiPath('api/access', params);
|
||||
const apiPath = getApiPath('api/access', params, true);
|
||||
return fetchJSON(apiPath, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
@@ -64,7 +64,7 @@ export async function del(source, path, body) {
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function updatePath(source, oldPath, newPath) {
|
||||
const apiPath = getApiPath('api/access');
|
||||
const apiPath = getApiPath('api/access', {}, true);
|
||||
return fetchJSON(apiPath, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ source, oldPath, newPath }),
|
||||
|
||||
+303
-47
@@ -1,11 +1,12 @@
|
||||
import { fetchURL, adjustedData } from './utils'
|
||||
import { getApiPath, doubleEncode, getPublicApiPath } from '@/utils/url.js'
|
||||
import { state } from '@/store'
|
||||
import { state, mutations } from '@/store'
|
||||
import { notify } from '@/notify'
|
||||
import { globalVars } from '@/utils/constants'
|
||||
import { downloadManager } from '@/utils/downloadManager'
|
||||
|
||||
// Notify if errors occur
|
||||
export async function fetchFiles(source, path, content = false) {
|
||||
export async function fetchFiles(source, path, content = false, metadata = false) {
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
}
|
||||
@@ -13,7 +14,8 @@ export async function fetchFiles(source, path, content = false) {
|
||||
const apiPath = getApiPath('api/resources', {
|
||||
path: doubleEncode(path),
|
||||
source: doubleEncode(source),
|
||||
...(content && { content: 'true' })
|
||||
...(content && { content: 'true' }),
|
||||
...(metadata && { metadata: 'true' })
|
||||
})
|
||||
const res = await fetchURL(apiPath)
|
||||
const data = await res.json()
|
||||
@@ -61,14 +63,37 @@ async function resourceAction(source, path, method, content) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(source, path) {
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
export async function bulkDelete(items) {
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
throw new Error('items array is required and must not be empty')
|
||||
}
|
||||
try {
|
||||
return await resourceAction( source, path, 'DELETE')
|
||||
const apiPath = getApiPath('api/resources/bulk/delete')
|
||||
const response = await fetchURL(apiPath, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(items),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
|
||||
// Both are valid responses that should be returned, not thrown as errors
|
||||
if (response.status === 200 || response.status === 207) {
|
||||
return data
|
||||
}
|
||||
|
||||
// For other error status codes, throw an error
|
||||
const error = new Error(data.message || response.statusText)
|
||||
error.status = response.status
|
||||
throw error
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error deleting resource')
|
||||
// Only show notification and re-throw if it's a real error (not 200/207)
|
||||
if (err.status && err.status !== 200 && err.status !== 207) {
|
||||
notify.showError(err.message || 'Error performing bulk delete')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -82,25 +107,59 @@ export async function put(source, path, content = '') {
|
||||
}
|
||||
|
||||
export async function download(format, files, shareHash = "") {
|
||||
// Check if chunked download should be used (single file only)
|
||||
const downloadChunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
|
||||
const sizeThreshold = downloadChunkSizeMb * 1024 * 1024;
|
||||
|
||||
const useChunkedDownload =
|
||||
downloadChunkSizeMb > 0 &&
|
||||
files.length === 1 &&
|
||||
!files[0].isDir &&
|
||||
files[0].size &&
|
||||
files[0].size >= sizeThreshold
|
||||
|
||||
if (useChunkedDownload) {
|
||||
// Use chunked download for large single files
|
||||
return await downloadChunked(files[0], shareHash)
|
||||
}
|
||||
|
||||
// Normal download (archive or small files)
|
||||
if (format !== 'zip') {
|
||||
format = 'tar.gz'
|
||||
}
|
||||
let fileargs = ''
|
||||
for (let file of files) {
|
||||
if (shareHash) {
|
||||
fileargs += encodeURIComponent(file.path) + '||'
|
||||
} else {
|
||||
fileargs += encodeURIComponent(file.source) + '::' + encodeURIComponent(file.path) + '||'
|
||||
|
||||
// For non-share downloads, validate single source and build file list
|
||||
let source = null
|
||||
let filePaths = []
|
||||
|
||||
if (shareHash) {
|
||||
// For shares, no source parameter needed, just paths
|
||||
filePaths = files.map(file => encodeURIComponent(file.path))
|
||||
} else {
|
||||
// Validate all files are from the same source
|
||||
for (let file of files) {
|
||||
if (!file.source) {
|
||||
throw new Error('File source is required for downloads')
|
||||
}
|
||||
if (source === null) {
|
||||
source = file.source
|
||||
} else if (source !== file.source) {
|
||||
throw new Error('All files must be from the same source for downloads')
|
||||
}
|
||||
filePaths.push(encodeURIComponent(file.path))
|
||||
}
|
||||
}
|
||||
fileargs = fileargs.slice(0, -2) // remove trailing "||"
|
||||
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', {
|
||||
files: fileargs,
|
||||
|
||||
const params = {
|
||||
files: filePaths.join(','),
|
||||
algo: format,
|
||||
hash: shareHash,
|
||||
...(shareHash && { hash: shareHash }),
|
||||
...(!shareHash && source && { source: encodeURIComponent(source) }),
|
||||
...(state.share.token && { token: state.share.token }),
|
||||
sessionId: state.sessionId
|
||||
})
|
||||
}
|
||||
|
||||
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
|
||||
const url = window.origin + apiPath
|
||||
|
||||
// Create a direct link and trigger the download
|
||||
@@ -120,6 +179,174 @@ export async function download(format, files, shareHash = "") {
|
||||
}, 100)
|
||||
}
|
||||
|
||||
async function downloadChunked(file, shareHash = "") {
|
||||
const chunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
|
||||
|
||||
if (chunkSizeMb === 0) {
|
||||
throw new Error("Chunked download is disabled (chunk size is 0)")
|
||||
}
|
||||
const chunkSize = chunkSizeMb * 1024 * 1024 // Convert MB to bytes
|
||||
const fileSize = file.size
|
||||
|
||||
// Extract filename from path if name is not available
|
||||
const fileName = file.name || (file.path ? file.path.split('/').pop() : 'download')
|
||||
|
||||
// Add to download manager
|
||||
const downloadId = downloadManager.add(file, shareHash)
|
||||
|
||||
downloadManager.setStatus(downloadId, "downloading")
|
||||
|
||||
// Show download prompt if not already shown (it should already be shown by downloadFiles, but check to be safe)
|
||||
const hasDownloadPrompt = state.hovers && state.hovers.some(h => h.name === 'download');
|
||||
|
||||
if (!hasDownloadPrompt) {
|
||||
mutations.showHover({ name: 'download' })
|
||||
}
|
||||
|
||||
// Build the download URL with new query format
|
||||
const params = {
|
||||
files: encodeURIComponent(file.path),
|
||||
...(shareHash && { hash: shareHash }),
|
||||
...(!shareHash && file.source && { source: encodeURIComponent(file.source) }),
|
||||
...(state.share.token && { token: state.share.token }),
|
||||
sessionId: state.sessionId
|
||||
}
|
||||
|
||||
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
|
||||
const baseUrl = window.origin + apiPath
|
||||
|
||||
const download = downloadManager.findById(downloadId)
|
||||
const abortController = new AbortController()
|
||||
download.abortController = abortController
|
||||
|
||||
try {
|
||||
// Download file in chunks
|
||||
const chunks = []
|
||||
let offset = 0
|
||||
let loaded = 0
|
||||
|
||||
while (offset < fileSize) {
|
||||
|
||||
const download = downloadManager.findById(downloadId);
|
||||
if (download && download.status === "cancelled") {
|
||||
// Silently handle cancellation - don't throw error
|
||||
return;
|
||||
}
|
||||
|
||||
const end = Math.min(offset + chunkSize - 1, fileSize - 1)
|
||||
const rangeHeader = `bytes=${offset}-${end}`
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
headers: {
|
||||
'Range': rangeHeader
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
signal: abortController.signal
|
||||
})
|
||||
|
||||
if (!response.ok && response.status !== 206) {
|
||||
throw new Error(`Failed to download chunk: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Track progress within the chunk using ReadableStream
|
||||
const expectedChunkSize = end - offset + 1;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunkParts = [];
|
||||
let chunkLoaded = 0;
|
||||
let lastProgressUpdate = 0;
|
||||
const progressUpdateInterval = Math.max(50000, expectedChunkSize / 50); // Update every ~2% of chunk or 50KB
|
||||
|
||||
try {
|
||||
let reading = true;
|
||||
while (reading) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
reading = false;
|
||||
break;
|
||||
}
|
||||
|
||||
chunkParts.push(value);
|
||||
chunkLoaded += value.length;
|
||||
|
||||
// Calculate progress: only count up to expected chunk size to avoid over-counting
|
||||
const chunkProgress = Math.min(chunkLoaded, expectedChunkSize);
|
||||
const totalLoaded = offset + chunkProgress;
|
||||
|
||||
// Update progress in real-time, but throttle updates for performance
|
||||
if (chunkLoaded - lastProgressUpdate >= progressUpdateInterval || chunkLoaded >= expectedChunkSize) {
|
||||
downloadManager.updateProgress(downloadId, totalLoaded, fileSize);
|
||||
lastProgressUpdate = chunkLoaded;
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
// If read was aborted, check if download was cancelled
|
||||
const download = downloadManager.findById(downloadId);
|
||||
if (readError.name === 'AbortError' || (download && download.status === "cancelled")) {
|
||||
downloadManager.setStatus(downloadId, "cancelled");
|
||||
return; // Silently handle cancellation
|
||||
}
|
||||
throw readError; // Re-throw other errors
|
||||
}
|
||||
|
||||
// Combine chunk parts into single ArrayBuffer
|
||||
const chunk = new Uint8Array(chunkLoaded);
|
||||
let position = 0;
|
||||
for (const part of chunkParts) {
|
||||
chunk.set(part, position);
|
||||
position += part.length;
|
||||
}
|
||||
|
||||
// Only use the expected chunk size portion if server returned more (handles Range header issues)
|
||||
const chunkToUse = chunk.byteLength > expectedChunkSize
|
||||
? chunk.slice(0, expectedChunkSize).buffer
|
||||
: chunk.buffer;
|
||||
|
||||
chunks.push(chunkToUse)
|
||||
// Always use expected chunk size for progress to avoid double-counting
|
||||
loaded += expectedChunkSize
|
||||
|
||||
// Final progress update for this chunk
|
||||
downloadManager.updateProgress(downloadId, loaded, fileSize)
|
||||
|
||||
offset = end + 1
|
||||
}
|
||||
|
||||
// Combine all chunks into a single blob
|
||||
const blob = new Blob(chunks, { type: 'application/octet-stream' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
// Trigger download
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = fileName
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
// Mark as completed
|
||||
downloadManager.setStatus(downloadId, "completed")
|
||||
downloadManager.updateProgress(downloadId, fileSize, fileSize)
|
||||
|
||||
// Clean up
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
// Check if download was cancelled by user
|
||||
const download = downloadManager.findById(downloadId);
|
||||
if (error.name === 'AbortError' || (download && download.status === "cancelled")) {
|
||||
downloadManager.setStatus(downloadId, "cancelled")
|
||||
// Don't throw error or show notification for user-initiated cancellation
|
||||
return;
|
||||
}
|
||||
downloadManager.setError(downloadId, error.message || 'Download failed')
|
||||
notify.showError(`Chunked download failed: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function post(
|
||||
source,
|
||||
path,
|
||||
@@ -201,38 +428,66 @@ export async function moveCopy(
|
||||
overwrite = false,
|
||||
rename = false
|
||||
) {
|
||||
let params = {
|
||||
overwrite: overwrite,
|
||||
action: action,
|
||||
rename: rename
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
throw new Error('items array is required and must not be empty')
|
||||
}
|
||||
try {
|
||||
// Create an array of fetch calls
|
||||
let promises = items.map(item => {
|
||||
let localParams = {
|
||||
...params,
|
||||
destination: doubleEncode(item.toSource) + '::' + doubleEncode(item.to),
|
||||
from: doubleEncode(item.fromSource) + '::' + doubleEncode(item.from)
|
||||
}
|
||||
const apiPath = getApiPath('api/resources', localParams)
|
||||
|
||||
return fetch(apiPath, { method: 'PATCH' }).then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
throw new Error(
|
||||
`Failed to move/copy: ${text || response.statusText}`
|
||||
)
|
||||
})
|
||||
}
|
||||
return response
|
||||
})
|
||||
try {
|
||||
// Build request body with proper format
|
||||
const requestBody = {
|
||||
items: items.map(item => ({
|
||||
fromSource: item.fromSource,
|
||||
fromPath: item.from,
|
||||
toSource: item.toSource,
|
||||
toPath: item.to
|
||||
})),
|
||||
action: action,
|
||||
overwrite: overwrite,
|
||||
rename: rename
|
||||
}
|
||||
|
||||
const apiPath = getApiPath('api/resources')
|
||||
// We use fetch directly here instead of fetchURL because fetchURL throws on non-2xx status,
|
||||
// consuming the response body as text. We need to parse the JSON response for 500/207 errors.
|
||||
// We need to manually add headers that fetchURL adds.
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'sessionId': state.sessionId
|
||||
}
|
||||
|
||||
const response = await fetch(apiPath, {
|
||||
method: 'PATCH',
|
||||
headers: headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
|
||||
// Await all promises and ensure errors propagate
|
||||
await Promise.all(promises)
|
||||
const data = await response.json()
|
||||
|
||||
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
|
||||
if (response.status === 200 || response.status === 207) {
|
||||
return data
|
||||
}
|
||||
|
||||
// For other error status codes (like 500), preserve the response data
|
||||
const error = new Error(data.message || response.statusText)
|
||||
error.status = response.status
|
||||
// Attach the response data so the caller can access failed items
|
||||
if (data.failed) {
|
||||
error.failed = data.failed
|
||||
}
|
||||
if (data.succeeded) {
|
||||
error.succeeded = data.succeeded
|
||||
}
|
||||
throw error
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error moving/copying resources')
|
||||
throw err // Re-throw the error to propagate it back to the caller
|
||||
// For 500 errors with response data, don't show notification here
|
||||
// Let the caller handle it with the attached error data
|
||||
// Only show notification for unexpected errors (no status or not 500)
|
||||
if (!err.status || (err.status !== 500 && err.status !== 200 && err.status !== 207)) {
|
||||
notify.showError(err.message || 'Error moving/copying resources')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +517,8 @@ export function getDownloadURL(source, path, inline, useExternal) {
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
files: encodeURIComponent(source) + '::' + encodeURIComponent(path),
|
||||
source: encodeURIComponent(source),
|
||||
files: encodeURIComponent(path),
|
||||
...(inline && { inline: 'true' })
|
||||
}
|
||||
const apiPath = getApiPath('api/raw', params)
|
||||
|
||||
+129
-35
@@ -13,14 +13,16 @@ import { state } from "@/store";
|
||||
* @param {string} hash
|
||||
* @param {string} password
|
||||
* @param {boolean} content
|
||||
* @param {boolean} metadata
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function fetchPub(path, hash, password = "", content = false) {
|
||||
export async function fetchPub(path, hash, password = "", content = false, metadata = false) {
|
||||
path = encodedPath(path);
|
||||
const params = {
|
||||
path: path,
|
||||
hash,
|
||||
...(content && { content: 'true' }),
|
||||
...(metadata && { metadata: 'true' }),
|
||||
...(state.share.token && { token: state.share.token })
|
||||
}
|
||||
const apiPath = getPublicApiPath("resources", params);
|
||||
@@ -141,12 +143,37 @@ export function post(
|
||||
error.response = { status: request.status, responseText: request.responseText };
|
||||
reject(error);
|
||||
} else {
|
||||
reject(new Error(request.responseText || "Upload failed"));
|
||||
// Parse error message from response
|
||||
let errorMessage = "Upload failed";
|
||||
try {
|
||||
const errorData = JSON.parse(request.responseText);
|
||||
errorMessage = errorData.message || errorMessage;
|
||||
} catch (e) {
|
||||
// If parsing fails, use responseText or default message
|
||||
errorMessage = request.responseText || errorMessage;
|
||||
}
|
||||
|
||||
const error = new Error(errorMessage);
|
||||
error.status = request.status;
|
||||
|
||||
// Show notification for upload errors
|
||||
notify.showError(errorMessage);
|
||||
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => reject(new Error("Network error"));
|
||||
request.onabort = () => reject(new Error("Upload aborted"));
|
||||
request.onerror = () => {
|
||||
const error = new Error("Network error");
|
||||
notify.showError("Network error during upload");
|
||||
reject(error);
|
||||
};
|
||||
|
||||
request.onabort = () => {
|
||||
const error = new Error("Upload aborted");
|
||||
notify.showError("Upload was aborted");
|
||||
reject(error);
|
||||
};
|
||||
|
||||
if (
|
||||
content instanceof Blob &&
|
||||
@@ -205,11 +232,60 @@ async function resourceAction(hash, path, method, content, token = "") {
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(hash, path) {
|
||||
export async function bulkDelete(items) {
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
throw new Error('items array is required and must not be empty')
|
||||
}
|
||||
|
||||
const hash = state.shareInfo?.hash;
|
||||
if (!hash) {
|
||||
throw new Error('share hash is required')
|
||||
}
|
||||
|
||||
const params = {
|
||||
hash: hash,
|
||||
...(state.share.token && { token: state.share.token }),
|
||||
sessionId: state.sessionId
|
||||
}
|
||||
const apiPath = getPublicApiPath("resources/bulk/delete", params)
|
||||
const baseUrl = window.origin + apiPath
|
||||
|
||||
try {
|
||||
return await resourceAction(hash, path, 'DELETE')
|
||||
const response = await fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(items),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
|
||||
// Both are valid responses that should be returned, not thrown as errors
|
||||
if (response.status === 200 || response.status === 207) {
|
||||
return data
|
||||
}
|
||||
|
||||
// For other error status codes, throw an error
|
||||
const error = new Error(data.message || response.statusText)
|
||||
error.status = response.status
|
||||
throw error
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error deleting resource')
|
||||
// If the request fails completely, return all items as failed
|
||||
if (err.status && err.status !== 200 && err.status !== 207) {
|
||||
// Real error - return all as failed
|
||||
return {
|
||||
succeeded: [],
|
||||
failed: items.map(item => ({
|
||||
source: item.source || '',
|
||||
path: item.path,
|
||||
message: err.message || 'Delete failed',
|
||||
})),
|
||||
}
|
||||
}
|
||||
// Re-throw if it's not a handled error
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -226,39 +302,57 @@ export async function moveCopy(
|
||||
overwrite = false,
|
||||
rename = false
|
||||
) {
|
||||
let params = {
|
||||
overwrite: overwrite,
|
||||
action: action,
|
||||
rename: rename,
|
||||
hash: hash
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
throw new Error('items array is required and must not be empty')
|
||||
}
|
||||
try {
|
||||
// Create an array of fetch calls
|
||||
let promises = items.map(item => {
|
||||
let localParams = {
|
||||
...params,
|
||||
destination: doubleEncode(item.to),
|
||||
from: doubleEncode(item.from)
|
||||
}
|
||||
const apiPath = getPublicApiPath('resources', localParams)
|
||||
|
||||
return fetch(apiPath, { method: 'PATCH' }).then(response => {
|
||||
if (!response.ok) {
|
||||
return response.text().then(text => {
|
||||
throw new Error(
|
||||
`Failed to move/copy: ${text || response.statusText}`
|
||||
)
|
||||
})
|
||||
}
|
||||
return response
|
||||
})
|
||||
try {
|
||||
// Build request body with proper format
|
||||
// For public shares, fromSource and toSource are not needed (always the share's source)
|
||||
const requestBody = {
|
||||
items: items.map(item => ({
|
||||
fromPath: item.from,
|
||||
toPath: item.to
|
||||
})),
|
||||
action: action,
|
||||
overwrite: overwrite,
|
||||
rename: rename
|
||||
}
|
||||
|
||||
const apiPath = getPublicApiPath('resources', { hash: hash })
|
||||
const response = await fetch(apiPath, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(state.share.token && { 'X-Auth-Token': state.share.token })
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
// Await all promises and ensure errors propagate
|
||||
await Promise.all(promises)
|
||||
const data = await response.json()
|
||||
|
||||
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
|
||||
if (response.status === 200 || response.status === 207) {
|
||||
return data
|
||||
}
|
||||
|
||||
// For other error status codes (like 500), preserve the response data
|
||||
const error = new Error(data.message || response.statusText)
|
||||
error.status = response.status
|
||||
// Attach the response data so the caller can access failed items
|
||||
if (data.failed) {
|
||||
error.failed = data.failed
|
||||
}
|
||||
if (data.succeeded) {
|
||||
error.succeeded = data.succeeded
|
||||
}
|
||||
throw error
|
||||
} catch (err) {
|
||||
console.error(err.message || 'Error moving/copying resources')
|
||||
throw err // Re-throw the error to propagate it back to the caller
|
||||
// Only show notification and re-throw if it's a real error (not 200/207)
|
||||
if (err.status && err.status !== 200 && err.status !== 207) {
|
||||
console.error(err.message || 'Error moving/copying resources')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,27 @@ import { fetchURL } from "./utils";
|
||||
import { notify } from "@/notify"; // Import notify for error handling
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
|
||||
export default async function search(base, source, query, largest = false) {
|
||||
export default async function search(base, sources, query, largest = false) {
|
||||
try {
|
||||
query = encodeURIComponent(query);
|
||||
if (!base.endsWith("/")) {
|
||||
base += "/";
|
||||
}
|
||||
|
||||
// Ensure sources is an array
|
||||
const sourcesArray = Array.isArray(sources) ? sources : [sources];
|
||||
|
||||
const params = {
|
||||
scope: encodeURIComponent(base),
|
||||
query: query,
|
||||
source: encodeURIComponent(source)
|
||||
sources: encodeURIComponent(sourcesArray.join(","))
|
||||
};
|
||||
|
||||
// Only include scope if searching a single source
|
||||
// When multiple sources are specified, scope is always the user's scope for each source
|
||||
if (sourcesArray.length === 1 && base) {
|
||||
if (!base.endsWith("/")) {
|
||||
base += "/";
|
||||
}
|
||||
params.scope = encodeURIComponent(base);
|
||||
}
|
||||
|
||||
if (largest) {
|
||||
params.largest = "true";
|
||||
}
|
||||
@@ -48,7 +57,13 @@ export async function findDuplicates(base, source, minSizeMb, useChecksum = fals
|
||||
const res = await fetchURL(apiPath);
|
||||
const data = await res.json();
|
||||
|
||||
return data;
|
||||
// Return both the data and metadata about completeness
|
||||
// Backend returns: { groups: [...], incomplete: bool, reason: string }
|
||||
return {
|
||||
groups: data.groups || data, // Handle both new format and legacy format
|
||||
incomplete: data.incomplete || false,
|
||||
reason: data.reason || ""
|
||||
};
|
||||
} catch (err) {
|
||||
notify.showError(err.message || "Error occurred while finding duplicates");
|
||||
throw err;
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
<template>
|
||||
<div id="breadcrumbs" :class="{ 'add-padding': addPadding }">
|
||||
<ul v-if="items.length > 0">
|
||||
<li>
|
||||
<router-link :to="base" :aria-label="$t('general.home')" :title="$t('general.home')">
|
||||
<i class="material-icons">home</i>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="item" v-for="(link, index) in items" :key="index">
|
||||
<router-link
|
||||
:to="link.url"
|
||||
:aria-label="'breadcrumb-link-' + link.name"
|
||||
:title="link.name"
|
||||
:key="index"
|
||||
:class="{ changeAvailable: hasUpdate }"
|
||||
>
|
||||
{{ link.name }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, getters } from "@/store";
|
||||
import { encodedPath } from "@/utils/url.js";
|
||||
|
||||
export default {
|
||||
name: "breadcrumbs",
|
||||
data() {
|
||||
return {
|
||||
base: "/files/",
|
||||
path: "",
|
||||
};
|
||||
},
|
||||
props: ["noLink"],
|
||||
mounted() {
|
||||
this.updatePaths();
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.updatePaths();
|
||||
},
|
||||
req() {
|
||||
this.updatePaths();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
req() {
|
||||
return state.req;
|
||||
},
|
||||
hasUpdate() {
|
||||
return state.req.hasUpdate;
|
||||
},
|
||||
addPadding() {
|
||||
return getters.isStickySidebar() || getters.isShare();
|
||||
},
|
||||
items() {
|
||||
const req = state.req;
|
||||
if (!req.items || !req.path) {
|
||||
return [];
|
||||
}
|
||||
let encodedPathString = encodedPath(state.req.path);
|
||||
let originalParts = state.req.path.split("/");
|
||||
let encodedParts = encodedPathString.split("/");
|
||||
// Remove empty strings from both arrays consistently
|
||||
if (originalParts[0] === "") {
|
||||
originalParts.shift();
|
||||
encodedParts.shift();
|
||||
}
|
||||
if (originalParts[originalParts.length - 1] === "") {
|
||||
originalParts.pop();
|
||||
encodedParts.pop();
|
||||
}
|
||||
let breadcrumbs = [];
|
||||
let buildRef = this.base;
|
||||
for (let i = 0; i < originalParts.length; i++) {
|
||||
const origPart = originalParts[i];
|
||||
const encodedElement = encodedParts[i];
|
||||
buildRef = buildRef + encodedElement + "/";
|
||||
breadcrumbs.push({
|
||||
name: origPart,
|
||||
url: buildRef,
|
||||
});
|
||||
}
|
||||
if (breadcrumbs.length > 3) {
|
||||
while (breadcrumbs.length !== 4) {
|
||||
breadcrumbs.shift();
|
||||
}
|
||||
breadcrumbs[0].name = "...";
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updatePaths() {
|
||||
if (getters.isShare()) {
|
||||
this.base = getters.sharePathBase();
|
||||
this.path = getters.routePath(this.base);
|
||||
|
||||
} else {
|
||||
this.path = encodedPath(state.req.path);
|
||||
this.base = `/files/${state.req.source}/`;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#breadcrumbs {
|
||||
margin-top: 0.5em;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
#breadcrumbs * {
|
||||
box-sizing: unset;
|
||||
}
|
||||
|
||||
#breadcrumbs ul {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li {
|
||||
display: inline-block;
|
||||
margin: 0 10px 0 0;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li a {
|
||||
display: flex;
|
||||
height: 1em;
|
||||
background: var(--alt-background);
|
||||
text-align: center;
|
||||
padding: 1em;
|
||||
padding-left: 2em;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
color: var(--textPrimary);
|
||||
border-radius: 0;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li a::after {
|
||||
content: "";
|
||||
border-top: 1.5em solid transparent;
|
||||
border-bottom: 1.5em solid transparent;
|
||||
border-left: 1.5em solid var(--alt-background);
|
||||
position: absolute;
|
||||
right: -1.5em;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li a::before {
|
||||
content: "";
|
||||
border-top: 1.5em solid transparent;
|
||||
border-bottom: 1.5em solid transparent;
|
||||
border-left: 1.5em solid var(--background);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:first-child a {
|
||||
border-top-left-radius: 1em;
|
||||
border-bottom-left-radius: 1em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:first-child a::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:last-child a {
|
||||
padding-right: 1.5em;
|
||||
border-top-right-radius: 1em;
|
||||
border-bottom-right-radius: 1em;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:last-child a::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li a:hover {
|
||||
background: var(--primaryColor);
|
||||
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);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -141,6 +141,8 @@
|
||||
<action icon="info" :label="$t('general.info')" show="info" />
|
||||
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('general.openFile')" @action="goToRaw()" />
|
||||
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
|
||||
<action v-if="isPreview && permissions.modify" icon="mode_edit" :label="$t('general.rename')" @action="showRenameHoverForPreview" />
|
||||
<action v-if="hasDownload && !req.isDir" icon="visibility" :label="$t('buttons.watchFile')" @action="watchFile()" />
|
||||
<action v-if="hasDownload" icon="file_download" :label="$t('general.download')" @action="startDownload" />
|
||||
<action v-if="showEdit" icon="edit" :label="$t('general.edit')" @action="edit()" />
|
||||
<action v-if="showSave" icon="save" :label="$t('general.save')" @action="save()" />
|
||||
@@ -428,7 +430,19 @@ export default {
|
||||
mutations.showHover({
|
||||
name: "rename",
|
||||
props: {
|
||||
item: getters.selectedCount() == 1 ? getters.getFirstSelected() : state.req
|
||||
item: getters.selectedCount() == 1 ? getters.getFirstSelected() : state.req,
|
||||
parentItems: []
|
||||
},
|
||||
});
|
||||
},
|
||||
showRenameHoverForPreview() {
|
||||
// Get parent items from the listing
|
||||
const parentItems = state.navigation.listing || [];
|
||||
mutations.showHover({
|
||||
name: "rename",
|
||||
props: {
|
||||
item: state.req,
|
||||
parentItems: parentItems,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -469,6 +483,18 @@ export default {
|
||||
window.open(downloadUrl, "_blank");
|
||||
mutations.closeHovers();
|
||||
},
|
||||
watchFile() {
|
||||
mutations.closeHovers();
|
||||
const source = state.req?.source || state.sources.current || "";
|
||||
const path = state.req?.path || "/";
|
||||
this.$router.push({
|
||||
path: "/tools/fileWatcher",
|
||||
query: {
|
||||
path: path,
|
||||
source: source,
|
||||
},
|
||||
});
|
||||
},
|
||||
async edit() {
|
||||
window.location.hash = "#edit";
|
||||
},
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<span
|
||||
class="loader"
|
||||
:class="[`loader--${size}`, mode !== 'spinner' ? `loader--${mode}` : '']"
|
||||
:style="{
|
||||
'--animation-duration': `${speed || (mode === 'spinner' ? 1 : 0.75)}s`,
|
||||
'--spinner-color': color
|
||||
}"
|
||||
>
|
||||
<span v-if="mode === 'bounce'" class="bounce-dot"></span>
|
||||
<span v-if="mode === 'bounce'" class="bounce-dot"></span>
|
||||
<div v-if="mode === 'placeholder'" class="placeholder-item placeholder-item-1"></div>
|
||||
<div v-if="mode === 'placeholder'" class="placeholder-item placeholder-item-2"></div>
|
||||
<div v-if="mode === 'placeholder'" class="placeholder-item placeholder-item-3"></div>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "LoadingSpinner",
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: "spinner",
|
||||
validator: (value) => ["spinner", "pulse", "bounce", "placeholder"].includes(value),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "medium",
|
||||
validator: (value) => ["xsmall", "small", "medium", "large"].includes(value),
|
||||
},
|
||||
speed: {
|
||||
type: Number,
|
||||
default: null,
|
||||
validator: (value) => value === null || value > 0,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "var(--primaryColor)",
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.loader {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
border-top: 3px solid var(--spinner-color, var(--primaryColor));
|
||||
border-right: 3px solid transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
box-sizing: border-box;
|
||||
animation: spinner-rotation var(--animation-duration, 1s) linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spinner-rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIZE VARIANTS
|
||||
============================================ */
|
||||
.loader--xsmall {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
.loader--small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.loader--medium {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.loader--large {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PULSE MODE
|
||||
============================================ */
|
||||
.loader--pulse {
|
||||
border: none;
|
||||
background: var(--spinner-color, var(--primaryColor));
|
||||
animation: pulse-scale var(--animation-duration, 0.75s) ease infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-scale {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BOUNCE MODE
|
||||
============================================ */
|
||||
.loader--bounce {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3em;
|
||||
border: none;
|
||||
background: transparent;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.loader--bounce::before,
|
||||
.loader--bounce::after {
|
||||
content: '';
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.loader--bounce::before {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.loader--bounce::after {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.loader--bounce .bounce-dot {
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
background-color: var(--spinner-color, var(--primaryColor));
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes bounce-scale {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Size variants for bounce mode */
|
||||
.loader--bounce.loader--xsmall::before,
|
||||
.loader--bounce.loader--xsmall::after,
|
||||
.loader--bounce.loader--xsmall .bounce-dot {
|
||||
width: 0.3em;
|
||||
height: 0.3em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--small::before,
|
||||
.loader--bounce.loader--small::after,
|
||||
.loader--bounce.loader--small .bounce-dot {
|
||||
width: 0.45em;
|
||||
height: 0.45em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--medium::before,
|
||||
.loader--bounce.loader--medium::after,
|
||||
.loader--bounce.loader--medium .bounce-dot {
|
||||
width: 0.6em;
|
||||
height: 0.6em;
|
||||
}
|
||||
|
||||
.loader--bounce.loader--large::before,
|
||||
.loader--bounce.loader--large::after,
|
||||
.loader--bounce.loader--large .bounce-dot {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PLACEHOLDER MODE
|
||||
============================================ */
|
||||
.loader--placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
border: none;
|
||||
animation: none;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.placeholder-item {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
animation: placeholder-ripple 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite,
|
||||
placeholder-bg-fade 2s ease-in-out infinite;
|
||||
display: block;
|
||||
border-radius: 0.25em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.placeholder-item-1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.placeholder-item-2 {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
|
||||
.placeholder-item-3 {
|
||||
animation-delay: 0.72s;
|
||||
}
|
||||
|
||||
@keyframes placeholder-ripple {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.07);
|
||||
box-shadow: inset 0 -3em 3em rgba(172, 172, 172, 0.211),
|
||||
0 0 0 2px var(--alt-background),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.97);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes placeholder-bg-fade {
|
||||
0% {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
50% {
|
||||
background-color: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Size variants for placeholder mode */
|
||||
.loader--placeholder.loader--xsmall .placeholder-item {
|
||||
width: 30px;
|
||||
height: 7.5px;
|
||||
min-width: 30px;
|
||||
min-height: 7.5px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--small .placeholder-item {
|
||||
width: 150px;
|
||||
height: 12.5px;
|
||||
min-width: 150px;
|
||||
min-height: 12.5px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--medium .placeholder-item {
|
||||
width: 200px;
|
||||
height: 25px;
|
||||
min-width: 200px;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
.loader--placeholder.loader--large .placeholder-item {
|
||||
width: 400px;
|
||||
height: 50px;
|
||||
min-width: 400px;
|
||||
min-height: 50px;
|
||||
}
|
||||
</style>
|
||||
@@ -115,7 +115,7 @@ export default {
|
||||
if (this.max <= 0) return 0;
|
||||
var pct = (this.val / this.max) * 100;
|
||||
var finalPct = Math.max(0, Math.min(pct.toFixed(2), 100));
|
||||
return finalPct < 7 ? 0 : finalPct;
|
||||
return finalPct > 0 && finalPct < 7 ? 6 : finalPct;
|
||||
},
|
||||
displayed_text() {
|
||||
if (!this.isValNumeric) return this.val;
|
||||
|
||||
@@ -22,12 +22,11 @@
|
||||
@keyup.exact="keyup" @input="submit" ref="input" :autofocus="active" v-model.trim="value"
|
||||
aria-label="search input" :placeholder="$t('general.search', { suffix: '...' })" />
|
||||
</div>
|
||||
|
||||
<!-- Search results for desktop -->
|
||||
<div v-show="active" id="results" class="fb-shadow" ref="result">
|
||||
<div class="inputWrapper">
|
||||
<select v-if="multipleSources" class="searchContext button input" aria-label="search sources dropdown"
|
||||
v-model="selectedSource" :value="selectedSource" @change="updateSource">
|
||||
<option value="__all__">{{ $t("general.all") }}</option>
|
||||
<option v-for="(info, name) in sourceInfo" :key="info" :value="name">
|
||||
{{ name }}
|
||||
</option>
|
||||
@@ -76,9 +75,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loading icon when search is ongoing -->
|
||||
<p v-show="isEmpty && isRunning" id="renew">
|
||||
<i class="material-icons spin">autorenew</i>
|
||||
</p>
|
||||
<LoadingSpinner v-if="ongoing" size="medium" />
|
||||
<!-- Message when no results are found -->
|
||||
<div class="searchPrompt" v-show="isEmpty && !isRunning">
|
||||
<p>{{ noneMessage }}</p>
|
||||
@@ -91,7 +88,7 @@
|
||||
<ul v-show="results.length > 0">
|
||||
<li v-for="(s, k) in results" :key="k" class="search-entry clickable"
|
||||
:class="{ active: activeStates[k], 'large-icons': showPreviewImages }" :aria-label="baseName(s.path)">
|
||||
<a :href="getRelative(s.path)" @contextmenu="addSelected(event, s)">
|
||||
<a :href="getItemUrl(s)" @contextmenu="addSelected(event, s)">
|
||||
<Icon :mimetype="s.type" :filename="baseName(s.path)"
|
||||
:hasPreview="showPreviewImages && (s.hasPreview || false)"
|
||||
:thumbnailUrl="showPreviewImages ? getThumbnailUrl(s) : ''" />
|
||||
@@ -100,6 +97,7 @@
|
||||
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</span>
|
||||
<div class="filesize">{{ humanSize(s.size) }}</div>
|
||||
<div v-if="isSearchingMultipleSources && s.source" class="source-badge">{{ s.source }}</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -117,6 +115,7 @@ import { url } from "@/utils/";
|
||||
import Icon from "@/components/files/Icon.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
|
||||
var boxes = {
|
||||
folder: { label: "folders", icon: "folder" },
|
||||
@@ -133,6 +132,7 @@ export default {
|
||||
ButtonGroup,
|
||||
Icon,
|
||||
ToggleSwitch,
|
||||
LoadingSpinner,
|
||||
},
|
||||
name: "search",
|
||||
data: function () {
|
||||
@@ -300,7 +300,14 @@ export default {
|
||||
multipleSources() {
|
||||
return Object.keys(state.sources.info).length > 1;
|
||||
},
|
||||
isSearchingMultipleSources() {
|
||||
return this.selectedSource === "__all__" || (this.selectedSource === "" && this.multipleSources);
|
||||
},
|
||||
getContext() {
|
||||
// If searching all sources, don't use context
|
||||
if (this.selectedSource === "__all__") {
|
||||
return "/";
|
||||
}
|
||||
let result = url.extractSourceFromPath(decodeURIComponent(state.route.path));
|
||||
if (this.selectedSource === "" || result.source === this.selectedSource) {
|
||||
return result.path;
|
||||
@@ -365,12 +372,10 @@ export default {
|
||||
this.selectedSource = event.target.value;
|
||||
this.submit();
|
||||
},
|
||||
getRelative(path) {
|
||||
const context = url.removeTrailingSlash(this.getContext)
|
||||
const encodedPath = url.encodePath(context + "/" + path)
|
||||
let fullpath = encodedPath;
|
||||
fullpath = globalVars.baseURL+"files/" + this.selectedSource + encodedPath;
|
||||
return fullpath;
|
||||
getItemUrl(s) {
|
||||
// Use source from result if available, otherwise fall back to selectedSource
|
||||
const source = s.source || this.selectedSource || state.sources.current;
|
||||
return url.buildItemUrl(source, s.path, true);
|
||||
},
|
||||
getIcon(mimetype) {
|
||||
return getMaterialIconForType(mimetype);
|
||||
@@ -466,11 +471,21 @@ export default {
|
||||
searchTypesFull = searchTypesFull + "type:smallerThan=" + this.smallerThan + " ";
|
||||
}
|
||||
this.ongoing = true;
|
||||
let source = this.selectedSource;
|
||||
if (source == "") {
|
||||
this.selectedSource = state.sources.current;
|
||||
|
||||
// Determine which sources to search
|
||||
let sourcesToSearch = [];
|
||||
if (this.selectedSource === "__all__" || this.selectedSource === "") {
|
||||
// Search all sources
|
||||
sourcesToSearch = Object.keys(this.sourceInfo);
|
||||
} else {
|
||||
// Search single source
|
||||
sourcesToSearch = [this.selectedSource || state.sources.current];
|
||||
}
|
||||
this.results = await search(this.getContext, this.selectedSource, searchTypesFull + this.value);
|
||||
|
||||
// Only pass scope if searching a single source
|
||||
const scope = sourcesToSearch.length === 1 ? this.getContext : null;
|
||||
|
||||
this.results = await search(scope, sourcesToSearch, searchTypesFull + this.value);
|
||||
|
||||
this.ongoing = false;
|
||||
if (this.results.length == 0) {
|
||||
@@ -496,7 +511,8 @@ export default {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const source = this.selectedSource || state.sources.current;
|
||||
// Use source from result if available, otherwise fall back to selectedSource
|
||||
const source = s.source || this.selectedSource || state.sources.current;
|
||||
const path = s.path;
|
||||
const modified = s.modified || "";
|
||||
return filesApi.getPreviewURL(source, path, modified);
|
||||
@@ -506,13 +522,15 @@ export default {
|
||||
},
|
||||
addSelected(event, s) {
|
||||
const pathParts = url.removeTrailingSlash(s.path).split("/");
|
||||
// Use source from result if available, otherwise fall back to selectedSource
|
||||
const source = s.source || this.selectedSource || state.sources.current;
|
||||
let path = this.getContext + url.removeTrailingSlash(s.path);
|
||||
const modifiedItem = {
|
||||
name: pathParts.pop(),
|
||||
path: path,
|
||||
size: s.size,
|
||||
type: s.type,
|
||||
source: this.selectedSource || state.sources.current,
|
||||
source: source,
|
||||
};
|
||||
mutations.resetSelected();
|
||||
mutations.addSelected(modifiedItem);
|
||||
@@ -866,6 +884,16 @@ body.rtl #search .boxes h3 {
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
background: var(--primaryColor);
|
||||
color: white;
|
||||
border-radius: 1em;
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
/* Smooth expand/collapse animation for search options */
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
@@ -954,5 +982,6 @@ body.rtl #search .boxes h3 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<transition name="shelf-slide">
|
||||
<div
|
||||
v-show="!isHidden && showShelf"
|
||||
id="shelf"
|
||||
:class="{ 'add-padding': addPadding }"
|
||||
>
|
||||
<breadcrumbs v-if="showBreadcrumbs" :base="isShare ? `/share/${shareHash}` : undefined" />
|
||||
<listing-header v-if="showListingHeader" :hasDuration="hasDuration" />
|
||||
<duplicate-finder-actions
|
||||
v-if="showDuplicateFinderActions"
|
||||
:selectedCount="duplicateFinderSelectedCount"
|
||||
:deleting="duplicateFinderDeleting"
|
||||
@delete="handleDuplicateFinderDelete"
|
||||
@clear="handleDuplicateFinderClear"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Breadcrumbs from "@/components/files/Breadcrumbs.vue";
|
||||
import ListingHeader from "@/components/files/ListingHeader.vue";
|
||||
import DuplicateFinderActions from "@/components/tools/DuplicateFinderActions.vue";
|
||||
import { state, getters } from "@/store";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "Shelf",
|
||||
components: {
|
||||
Breadcrumbs,
|
||||
ListingHeader,
|
||||
DuplicateFinderActions,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHidden: false,
|
||||
lastScrollTop: 0,
|
||||
scrollElement: null,
|
||||
scrollFrame: null,
|
||||
// Duplicate finder state
|
||||
duplicateFinderSelectedCount: 0,
|
||||
duplicateFinderDeleting: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
addPadding() {
|
||||
return getters.isStickySidebar() || getters.isShare();
|
||||
},
|
||||
showShelf() {
|
||||
return this.showBreadcrumbs || this.showListingHeader || this.showDuplicateFinderActions;
|
||||
},
|
||||
showBreadcrumbs() {
|
||||
return getters.showBreadCrumbs();
|
||||
},
|
||||
isShare() {
|
||||
return getters.isShare();
|
||||
},
|
||||
shareHash() {
|
||||
return getters.shareHash();
|
||||
},
|
||||
showListingHeader() {
|
||||
// Show listing header when in listing view with items
|
||||
return getters.currentView() === 'listingView' && state.req?.items?.length > 0;
|
||||
},
|
||||
hasDuration() {
|
||||
// Check if any file has duration metadata
|
||||
if (!state.req?.items) return false;
|
||||
return state.req.items.some(item =>
|
||||
item.type !== 'directory' && item.metadata && item.metadata.duration
|
||||
);
|
||||
},
|
||||
showDuplicateFinderActions() {
|
||||
// Show duplicate finder actions when on that route and there are selected items
|
||||
return this.duplicateFinderSelectedCount > 0;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
this.attachScrollListener();
|
||||
});
|
||||
eventBus.on('duplicateFinderSelectionChanged', this.handleDuplicateFinderSelectionChanged);
|
||||
eventBus.on('duplicateFinderDeletingChanged', this.handleDuplicateFinderDeletingChanged);
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.detachScrollListener();
|
||||
|
||||
// Clean up event bus listeners
|
||||
eventBus.off('duplicateFinderSelectionChanged', this.handleDuplicateFinderSelectionChanged);
|
||||
eventBus.off('duplicateFinderDeletingChanged', this.handleDuplicateFinderDeletingChanged);
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.isHidden = false;
|
||||
this.lastScrollTop = 0;
|
||||
this.$nextTick(() => {
|
||||
this.detachScrollListener();
|
||||
this.attachScrollListener();
|
||||
});
|
||||
},
|
||||
showShelf(newValue, oldValue) {
|
||||
// When shelf content appears (false -> true), always show it regardless of scroll position
|
||||
if (newValue === true && oldValue === false) {
|
||||
this.isHidden = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
attachScrollListener() {
|
||||
// Try to find the scroll wrapper (used by Scrollbar component)
|
||||
const scrollWrapper = document.querySelector('.scroll-wrapper');
|
||||
if (scrollWrapper) {
|
||||
scrollWrapper.addEventListener('scroll', this.handleScroll, { passive: true });
|
||||
this.scrollElement = scrollWrapper;
|
||||
}
|
||||
},
|
||||
|
||||
detachScrollListener() {
|
||||
if (this.scrollElement) {
|
||||
this.scrollElement.removeEventListener('scroll', this.handleScroll);
|
||||
this.scrollElement = null;
|
||||
}
|
||||
// Cancel any pending animation frame
|
||||
if (this.scrollFrame) {
|
||||
cancelAnimationFrame(this.scrollFrame);
|
||||
this.scrollFrame = null;
|
||||
}
|
||||
},
|
||||
|
||||
handleScroll(event) {
|
||||
// Use requestAnimationFrame to throttle updates (like Scrollbar component)
|
||||
if (this.scrollFrame) return;
|
||||
|
||||
this.scrollFrame = requestAnimationFrame(() => {
|
||||
const scrollTop = event.target.scrollTop;
|
||||
|
||||
// Always show when at the top
|
||||
if (scrollTop <= 10) {
|
||||
this.isHidden = false;
|
||||
this.lastScrollTop = scrollTop;
|
||||
this.scrollFrame = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate scroll difference
|
||||
const scrollDiff = scrollTop - this.lastScrollTop;
|
||||
|
||||
// Only trigger if scrolled enough (at least 30px)
|
||||
if (Math.abs(scrollDiff) < 30) {
|
||||
this.scrollFrame = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide when scrolling down, show when scrolling up
|
||||
if (scrollDiff > 0) {
|
||||
// Scrolling down
|
||||
this.isHidden = true;
|
||||
} else {
|
||||
// Scrolling up
|
||||
this.isHidden = false;
|
||||
}
|
||||
|
||||
this.lastScrollTop = scrollTop;
|
||||
this.scrollFrame = null;
|
||||
});
|
||||
},
|
||||
|
||||
// Duplicate finder event handlers
|
||||
handleDuplicateFinderSelectionChanged(count) {
|
||||
this.duplicateFinderSelectedCount = count;
|
||||
},
|
||||
handleDuplicateFinderDeletingChanged(deleting) {
|
||||
this.duplicateFinderDeleting = deleting;
|
||||
},
|
||||
handleDuplicateFinderDelete() {
|
||||
eventBus.emit('duplicateFinderDeleteRequested');
|
||||
},
|
||||
handleDuplicateFinderClear() {
|
||||
eventBus.emit('duplicateFinderClearRequested');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#shelf {
|
||||
|
||||
overflow-y: visible;
|
||||
overflow-x: hidden;
|
||||
position: fixed;
|
||||
padding: 0.5em;
|
||||
z-index: 1000;
|
||||
right: 0;
|
||||
left: 0;
|
||||
transition: 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.shelf-slide-enter-active,
|
||||
.shelf-slide-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.shelf-slide-enter-from,
|
||||
.shelf-slide-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.shelf-slide-enter-to,
|
||||
.shelf-slide-leave-from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Backdrop-filter support */
|
||||
@supports (backdrop-filter: none) {
|
||||
#shelf {
|
||||
backdrop-filter: blur(12px) invert(0.01);
|
||||
background-color: color-mix(in srgb, var(--background) 75%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
#main.moveWithSidebar #shelf {
|
||||
padding-left: 20.5em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<div v-if="items.length > 0" id="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="base" :aria-label="$t('general.home')" :title="$t('general.home')"
|
||||
:class="{ 'droppable-breadcrumb': isDroppable, 'drag-over': dragOverItem?.type === 'home' }"
|
||||
@dragenter.prevent="dragEnter($event, homeLink)"
|
||||
@dragleave.prevent="dragLeave($event, homeLink)"
|
||||
@dragover.prevent="dragOver($event, homeLink)"
|
||||
@drop.prevent="drop($event, homeLink)">
|
||||
<i class="material-icons">home</i>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="item" v-for="(link, index) in items" :key="index">
|
||||
<router-link
|
||||
:to="link.url"
|
||||
:aria-label="'breadcrumb-link-' + link.name"
|
||||
:title="link.name"
|
||||
:key="index"
|
||||
:class="{ changeAvailable: hasUpdate,
|
||||
'droppable-breadcrumb': isDroppable && link.type !== 'truncated',
|
||||
'drag-over': dragOverItem?.path === link.path, }"
|
||||
@dragenter="dragEnter($event, link)"
|
||||
@dragleave="dragLeave($event, link)"
|
||||
@dragover="dragOver($event, link)"
|
||||
@drop="drop($event, link)">
|
||||
{{ link.name }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { url } from "@/utils";
|
||||
import { filesApi, publicApi } from "@/api";
|
||||
import { notify } from "@/notify";
|
||||
|
||||
export default {
|
||||
name: "breadcrumbs",
|
||||
data() {
|
||||
return {
|
||||
base: "/files/",
|
||||
dragOverItem: null, // Track which breadcrumb is being dragged over
|
||||
};
|
||||
},
|
||||
props: ["noLink"],
|
||||
mounted() {
|
||||
this.updatePaths();
|
||||
window.addEventListener('dragend', this.clearDragState);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('dragend', this.clearDragState);
|
||||
},
|
||||
watch: {
|
||||
$route() {
|
||||
this.updatePaths();
|
||||
},
|
||||
req() {
|
||||
this.updatePaths();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
req() {
|
||||
return state.req;
|
||||
},
|
||||
hasUpdate() {
|
||||
return state.req.hasUpdate;
|
||||
},
|
||||
isDroppable() {
|
||||
return getters.permissions()?.modify
|
||||
},
|
||||
homeLink() {
|
||||
return {
|
||||
name: this.$t('general.home'),
|
||||
url: this.base,
|
||||
path: '/',
|
||||
type: 'home',
|
||||
};
|
||||
},
|
||||
scrollRatio() {
|
||||
const ratio = state.listing?.scrollRatio || 0;
|
||||
return ratio;
|
||||
},
|
||||
items() {
|
||||
const req = state.req;
|
||||
if (!req.items || !req.path) {
|
||||
return [];
|
||||
}
|
||||
let encodedPathString = url.encodedPath(state.req.path);
|
||||
let originalParts = state.req.path.split("/");
|
||||
let encodedParts = encodedPathString.split("/");
|
||||
// Remove empty strings from both arrays consistently
|
||||
if (originalParts[0] === "") {
|
||||
originalParts.shift();
|
||||
encodedParts.shift();
|
||||
}
|
||||
if (originalParts[originalParts.length - 1] === "") {
|
||||
originalParts.pop();
|
||||
encodedParts.pop();
|
||||
}
|
||||
let breadcrumbs = [];
|
||||
let buildRef = this.base;
|
||||
let accumulatedPath = "";
|
||||
|
||||
for (let i = 0; i < originalParts.length; i++) {
|
||||
const origPart = originalParts[i];
|
||||
const encodedElement = encodedParts[i];
|
||||
buildRef = buildRef + encodedElement + "/";
|
||||
accumulatedPath = accumulatedPath ? `${accumulatedPath}/${origPart}` : origPart;
|
||||
|
||||
breadcrumbs.push({
|
||||
name: origPart,
|
||||
url: buildRef,
|
||||
path: `/${accumulatedPath}`,
|
||||
type: 'normal'
|
||||
});
|
||||
}
|
||||
if (breadcrumbs.length > 3) {
|
||||
while (breadcrumbs.length !== 4) {
|
||||
breadcrumbs.shift();
|
||||
}
|
||||
breadcrumbs[0] = {
|
||||
name: "...",
|
||||
url: breadcrumbs[0].url, // Keep the URL of the first visible breadcrumb
|
||||
path: breadcrumbs[0].path, // Same here, but with the path
|
||||
type: 'truncated'
|
||||
};
|
||||
}
|
||||
return breadcrumbs;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updatePaths() {
|
||||
if (getters.isShare()) {
|
||||
this.base = getters.sharePathBase();
|
||||
} else {
|
||||
this.base = `/files/${state.req.source}/`;
|
||||
}
|
||||
},
|
||||
|
||||
dragEnter(event, link) {
|
||||
// Don't allow drag over "..." (is a truncated breadcrumb)
|
||||
if (!this.isDroppable || link.type === 'truncated') return;
|
||||
if (!event.dataTransfer.types.includes("application/x-filebrowser-internal-drag")) return;
|
||||
event.preventDefault();
|
||||
this.dragOverItem = link;
|
||||
},
|
||||
|
||||
dragOver(event, link) {
|
||||
if (!this.isDroppable || link.type === 'truncated') return;
|
||||
if (!event.dataTransfer.types.includes("application/x-filebrowser-internal-drag")) return;
|
||||
event.preventDefault();
|
||||
},
|
||||
|
||||
dragLeave(event, link) {
|
||||
// Don't clear drag state if we're just moving to a child element
|
||||
if (event.currentTarget.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dragOverItem?.path === link.path ||
|
||||
(this.dragOverItem?.type === 'home' && link.type === 'home')) {
|
||||
this.clearDragState();
|
||||
}
|
||||
},
|
||||
|
||||
clearDragState() {
|
||||
this.dragOverItem = null;
|
||||
},
|
||||
|
||||
async drop(event, link) {
|
||||
// Don't allow drop on "..."
|
||||
if (link.type === 'truncated') return;
|
||||
if (!this.isDroppable) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.clearDragState();
|
||||
|
||||
const isInternal = Array.from(event.dataTransfer.types).includes(
|
||||
"application/x-filebrowser-internal-drag"
|
||||
);
|
||||
|
||||
if (!isInternal) return;
|
||||
|
||||
// Get the target path for this breadcrumb
|
||||
const targetPath = link.path; // Use the path from the link object
|
||||
const currentPath = state.req.path;
|
||||
const source = state.req.source;
|
||||
|
||||
// debug log paths when dropping for testing
|
||||
console.log("Drop target:", {
|
||||
link,
|
||||
targetPath,
|
||||
currentPath,
|
||||
items: this.items
|
||||
});
|
||||
|
||||
// Normalize paths for comparison
|
||||
const normalizePath = (path) => {
|
||||
if (!path || path === "/") return "/";
|
||||
return path.replace(/\/$/, ''); // Remove trailing slash
|
||||
};
|
||||
|
||||
const normalizedTarget = normalizePath(targetPath);
|
||||
const normalizedCurrent = normalizePath(currentPath);
|
||||
|
||||
if (normalizedTarget === normalizedCurrent) {
|
||||
notify.showErrorToast(this.$t("files.sameFolder"));
|
||||
console.log("Cannot move to same folder");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build list of items to move from selected items
|
||||
let itemsToMove = [];
|
||||
for (let i of state.selected) {
|
||||
if (i < 0 || i >= state.req.length) continue;
|
||||
|
||||
const selectedItem = state.req.items[i];
|
||||
|
||||
let fromPath = selectedItem.path;
|
||||
|
||||
if (!fromPath) {
|
||||
fromPath = url.joinPath(state.req.path, selectedItem.name);
|
||||
}
|
||||
|
||||
itemsToMove.push({
|
||||
from: fromPath,
|
||||
fromSource: selectedItem.source,
|
||||
to: url.joinPath(targetPath, selectedItem.name),
|
||||
toSource: source,
|
||||
itemType: selectedItem.type
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out invalid moves
|
||||
itemsToMove = itemsToMove.filter(item => {
|
||||
if (item.from === item.to) return false;
|
||||
|
||||
// Prevent moving a directory into itself -- likely never will happen but just in case
|
||||
if (item.itemType === 'directory') {
|
||||
const fromDir = normalizePath(item.from);
|
||||
const toDir = normalizePath(item.to);
|
||||
|
||||
// Check if destination is inside the directory
|
||||
if (toDir.startsWith(fromDir + "/")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Check for conflicts in target directory
|
||||
let targetDirItems = [];
|
||||
try {
|
||||
if (getters.isShare()) {
|
||||
const response = await publicApi.fetchPub(targetPath, state.shareInfo.hash);
|
||||
targetDirItems = response?.items;
|
||||
} else {
|
||||
const response = await filesApi.fetchFiles(source, targetPath);
|
||||
targetDirItems = response?.items;
|
||||
}
|
||||
} catch (error) {
|
||||
notify.showErrorToast(this.$t("files.cannotAccesDir"));
|
||||
console.log("Cannot access to target directory", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// if any item conflics will show replace-rename prompt later
|
||||
const conflict = itemsToMove.some(item => {
|
||||
const itemName = item.to.split('/').pop(); // Extract filename from destination path
|
||||
return targetDirItems.some(targetItem => targetItem.name === itemName);
|
||||
});
|
||||
|
||||
const moveAction = async (overwrite, rename) => {
|
||||
mutations.showHover({
|
||||
name: "move",
|
||||
props: { operationInProgress: true },
|
||||
});
|
||||
|
||||
try {
|
||||
if (getters.isShare()) {
|
||||
await publicApi.moveCopy(state.shareInfo.hash, itemsToMove, "move", overwrite, rename);
|
||||
} else {
|
||||
await filesApi.moveCopy(itemsToMove, "move", overwrite, rename);
|
||||
}
|
||||
|
||||
const buttonAction = () => {
|
||||
url.goToItem(source, targetPath, {});
|
||||
};
|
||||
|
||||
notify.showSuccess(this.$t("prompts.moveSuccess"), {
|
||||
icon: "folder",
|
||||
buttons: [{
|
||||
label: this.$t("buttons.goToItem"),
|
||||
primary: true,
|
||||
action: buttonAction
|
||||
}]
|
||||
});
|
||||
mutations.closeHovers();
|
||||
mutations.setReload(true);
|
||||
} catch (error) {
|
||||
mutations.closeHovers();
|
||||
notify.showErrorToast(this.$t("prompts.moveFailed"));
|
||||
console.log("Move failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
if (conflict) {
|
||||
mutations.showHover({
|
||||
name: "replace-rename",
|
||||
confirm: (event, option) => {
|
||||
const overwrite = option === "overwrite";
|
||||
const rename = option === "rename";
|
||||
event.preventDefault();
|
||||
mutations.closeHovers();
|
||||
moveAction(overwrite, rename);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If no conflicts, proceed with move
|
||||
await moveAction(false, false);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#breadcrumbs {
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#breadcrumbs * {
|
||||
box-sizing: unset;
|
||||
}
|
||||
|
||||
#breadcrumbs ul {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li {
|
||||
display: inline-block;
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li a {
|
||||
display: flex;
|
||||
height: 0.85em;
|
||||
background: color-mix(in srgb, var(--alt-background) 90%, transparent);
|
||||
text-align: center;
|
||||
padding: 0.85em;
|
||||
padding-left: 1.7em;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
color: var(--textPrimary);
|
||||
border-radius: 0;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li a::after {
|
||||
content: "";
|
||||
border-top: 1.275em solid transparent;
|
||||
border-bottom: 1.275em solid transparent;
|
||||
border-left: 1.275em solid var(--alt-background);
|
||||
border-left-color: color-mix(in srgb, var(--alt-background) 90%, transparent);
|
||||
position: absolute;
|
||||
right: -1.25em;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li a::before {
|
||||
content: "";
|
||||
border-top: 1.275em solid transparent;
|
||||
border-bottom: 1.275em solid transparent;
|
||||
border-left: 1.275em solid var(--background);
|
||||
border-left-color: color-mix(in srgb, var(--background) 85%, transparent);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:first-child a {
|
||||
border-top-left-radius: 0.85em;
|
||||
border-bottom-left-radius: 0.85em;
|
||||
padding-left: 1.275em;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:first-child a::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:last-child a {
|
||||
padding-right: 1.275em;
|
||||
border-top-right-radius: 0.85em;
|
||||
border-bottom-right-radius: 0.85em;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li:last-child a::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#breadcrumbs ul li a:hover {
|
||||
background: var(--primaryColor);
|
||||
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);
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
background: var(--primaryColor) !important; /* Needs !important to make the hover effect work when dragging items */
|
||||
color: white !important;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.drag-over::after {
|
||||
border-left-color: var(--primaryColor) !important;
|
||||
}
|
||||
|
||||
@keyframes breadcrumbPulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
animation: breadcrumbPulse 0.5s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -106,7 +106,7 @@ export default {
|
||||
if (!getters.previewPerms().folder && this.mimetype == "directory") {
|
||||
return false;
|
||||
}
|
||||
return this.imageState !== 'error' && !this.disablePreviewExt && !this.officeFileDisabled
|
||||
return this.imageState !== 'error' && !this.disablePreviewExt && !this.officeFileDisabled;
|
||||
},
|
||||
disablePreviewExt() {
|
||||
const ext = "." + (this.filename.split(".").pop() || "").toLowerCase(); // Ensure lowercase and dot
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="listing-item-header card" :class="{ 'dark-mode': isDarkMode, 'desktop-view': !isMobile }">
|
||||
<p
|
||||
:class="{ active: nameSorted }"
|
||||
class="name"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="sort('name')"
|
||||
:title="$t('files.sortByName')"
|
||||
:aria-label="$t('files.sortByName')"
|
||||
>
|
||||
<span>{{ $t("general.name") }}</span>
|
||||
<i class="material-icons">{{ nameIcon }}</i>
|
||||
</p>
|
||||
|
||||
<p
|
||||
:class="{ active: sizeSorted }"
|
||||
class="size"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="sort('size')"
|
||||
:title="$t('files.sortBySize')"
|
||||
:aria-label="$t('files.sortBySize')"
|
||||
>
|
||||
<span>{{ $t("general.size") }}</span>
|
||||
<i class="material-icons">{{ sizeIcon }}</i>
|
||||
</p>
|
||||
|
||||
<p
|
||||
:class="{ active: modifiedSorted }"
|
||||
class="modified"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="sort('modified')"
|
||||
:title="$t('files.sortByLastModified')"
|
||||
:aria-label="$t('files.sortByLastModified')"
|
||||
>
|
||||
<span>{{ $t("files.lastModified") }}</span>
|
||||
<i class="material-icons">{{ modifiedIcon }}</i>
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="hasDuration"
|
||||
:class="{ active: durationSorted }"
|
||||
class="duration"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="sort('duration')"
|
||||
:title="$t('files.sortByDuration')"
|
||||
:aria-label="$t('files.sortByDuration')"
|
||||
>
|
||||
<span>{{ $t("files.duration") }}</span>
|
||||
<i class="material-icons">{{ durationIcon }}</i>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, getters, mutations } from "@/store";
|
||||
|
||||
export default {
|
||||
name: "ListingHeader",
|
||||
props: {
|
||||
hasDuration: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isMobile() {
|
||||
return state.isMobile;
|
||||
},
|
||||
isDarkMode() {
|
||||
return getters.isDarkMode();
|
||||
},
|
||||
nameSorted() {
|
||||
return getters.sorting().by === "name";
|
||||
},
|
||||
sizeSorted() {
|
||||
return getters.sorting().by === "size";
|
||||
},
|
||||
modifiedSorted() {
|
||||
return getters.sorting().by === "modified";
|
||||
},
|
||||
durationSorted() {
|
||||
return getters.sorting().by === "duration";
|
||||
},
|
||||
ascOrdered() {
|
||||
return getters.sorting().asc;
|
||||
},
|
||||
nameIcon() {
|
||||
if (this.nameSorted && !this.ascOrdered) {
|
||||
return "arrow_upward";
|
||||
}
|
||||
return "arrow_downward";
|
||||
},
|
||||
sizeIcon() {
|
||||
if (this.sizeSorted && this.ascOrdered) {
|
||||
return "arrow_downward";
|
||||
}
|
||||
return "arrow_upward";
|
||||
},
|
||||
modifiedIcon() {
|
||||
if (this.modifiedSorted && this.ascOrdered) {
|
||||
return "arrow_downward";
|
||||
}
|
||||
return "arrow_upward";
|
||||
},
|
||||
durationIcon() {
|
||||
if (this.durationSorted && this.ascOrdered) {
|
||||
return "arrow_downward";
|
||||
}
|
||||
return "arrow_upward";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
sort(field) {
|
||||
let asc = false;
|
||||
if (
|
||||
(field === "name" && this.nameIcon === "arrow_upward") ||
|
||||
(field === "size" && this.sizeIcon === "arrow_upward") ||
|
||||
(field === "modified" && this.modifiedIcon === "arrow_upward") ||
|
||||
(field === "duration" && this.durationIcon === "arrow_upward")
|
||||
) {
|
||||
asc = true;
|
||||
}
|
||||
// Commit the updateSort mutation
|
||||
mutations.updateListingSortConfig({ field, asc });
|
||||
mutations.updateListingItems();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.listing-item-header {
|
||||
display: flex;
|
||||
background: white;
|
||||
border: 1px solid rgba(0, 0, 0, .1);
|
||||
z-index: 999;
|
||||
padding: .85em;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-top-left-radius: 1em;
|
||||
border-top-right-radius: 1em;
|
||||
border: unset;
|
||||
margin-bottom: 0 !important;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dark-mode {
|
||||
border-color: var(--divider) !important;
|
||||
background: var(--surfacePrimary) !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-right: 1.5em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.size {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.duration {
|
||||
margin-left: auto;
|
||||
padding-right: 1em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.desktop-view {
|
||||
justify-content: unset !important;
|
||||
}
|
||||
|
||||
/* Desktop-specific column widths */
|
||||
.desktop-view .name {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.desktop-view .size {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 1.5em;
|
||||
vertical-align: middle;
|
||||
margin-left: .2em;
|
||||
opacity: 0;
|
||||
transition: .1s ease all;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
p:hover i,
|
||||
.active i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<a
|
||||
v-if="isClickable"
|
||||
:href="getUrl()"
|
||||
class="listing-item clickable no-select"
|
||||
:class="{
|
||||
@@ -68,6 +69,56 @@
|
||||
:clickable=true
|
||||
/>
|
||||
</a>
|
||||
<div
|
||||
v-else
|
||||
class="listing-item no-select clickable"
|
||||
:class="{
|
||||
activebutton: isSelected,
|
||||
hiddenFile: isHiddenNotSelected && this && !this.isDraggedOver,
|
||||
'half-selected': isDraggedOver,
|
||||
'drag-hover': isDraggedOver,
|
||||
'out-of-view': !isInView && !isSelected,
|
||||
}"
|
||||
:id="getID"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:data-dir="isDir"
|
||||
:data-type="type"
|
||||
:data-name="name"
|
||||
:data-index="index"
|
||||
:aria-label="name"
|
||||
:aria-selected="isSelected"
|
||||
@contextmenu="onRightClick"
|
||||
@click="click"
|
||||
@touchstart="addSelected"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="cancelContext"
|
||||
@mouseup="cancelContext"
|
||||
>
|
||||
<div :class="{ 'gallery-div': galleryView }">
|
||||
<Icon
|
||||
:mimetype="type"
|
||||
:thumbnailUrl="isThumbnailInView ? thumbnailUrl : ''"
|
||||
:filename="name"
|
||||
:hasPreview="hasPreview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text">
|
||||
<p :class="{ adjustment: quickDownloadEnabled }" class="name">{{ displayName }}</p>
|
||||
<p
|
||||
class="size"
|
||||
:class="{ adjustment: quickDownloadEnabled }"
|
||||
:data-order="humanSize()"
|
||||
>
|
||||
{{ humanSize() }}
|
||||
</p>
|
||||
<p class="modified">
|
||||
<time :datetime="modified">{{ getTime() }}</time>
|
||||
</p>
|
||||
<p v-if="hasDuration" class="duration">{{ getDuration() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -99,25 +150,42 @@ export default {
|
||||
isDraggedOver: false,
|
||||
contextTimeout: null,
|
||||
observer: null, // Store observer reference
|
||||
localSelected: false, // Track local selection when updateGlobalState is false
|
||||
};
|
||||
},
|
||||
props: [
|
||||
"name",
|
||||
"isDir",
|
||||
"source",
|
||||
"type",
|
||||
"size",
|
||||
"modified",
|
||||
"index",
|
||||
"readOnly",
|
||||
"path",
|
||||
"reducedOpacity",
|
||||
"hash",
|
||||
"hasPreview",
|
||||
"metadata",
|
||||
"hasDuration",
|
||||
"displayFullPath",
|
||||
],
|
||||
props: {
|
||||
name: String,
|
||||
isDir: Boolean,
|
||||
source: String,
|
||||
type: String,
|
||||
size: Number,
|
||||
modified: String,
|
||||
index: [Number, String],
|
||||
readOnly: Boolean,
|
||||
path: String,
|
||||
reducedOpacity: Boolean,
|
||||
hash: String,
|
||||
hasPreview: Boolean,
|
||||
metadata: Object,
|
||||
hasDuration: Boolean,
|
||||
displayFullPath: Boolean,
|
||||
updateGlobalState: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isSelectedProp: {
|
||||
type: Boolean,
|
||||
default: null, // null means use internal state
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
forceFilesApi: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
displayName() {
|
||||
// If displayFullPath is true, show the full path, otherwise just the name
|
||||
@@ -158,6 +226,10 @@ export default {
|
||||
return this.isSelected;
|
||||
},
|
||||
isSelected() {
|
||||
if (!this.updateGlobalState) {
|
||||
// If parent provides isSelectedProp, use it; otherwise use local state
|
||||
return this.isSelectedProp !== null ? this.isSelectedProp : this.localSelected;
|
||||
}
|
||||
return state.selected.indexOf(this.index) !== -1;
|
||||
},
|
||||
isDraggable() {
|
||||
@@ -201,6 +273,12 @@ export default {
|
||||
return "";
|
||||
}
|
||||
|
||||
// If forceFilesApi is true, always use authenticated files API
|
||||
if (this.forceFilesApi) {
|
||||
// @ts-ignore
|
||||
return filesApi.getPreviewURL(this.source || state.req.source, previewPath, this.modified);
|
||||
}
|
||||
|
||||
if (getters.isShare()) {
|
||||
return publicApi.getPreviewURL(previewPath);
|
||||
}
|
||||
@@ -210,6 +288,9 @@ export default {
|
||||
isThumbsEnabled() {
|
||||
return globalVars.enableThumbs;
|
||||
},
|
||||
isClickable() {
|
||||
return this.clickable;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Set up IntersectionObserver with larger margins for smoother rendering
|
||||
@@ -220,6 +301,14 @@ export default {
|
||||
});
|
||||
|
||||
this.observer.observe(this.$el);
|
||||
this.$nextTick(() => {
|
||||
const rect = this.$el.getBoundingClientRect();
|
||||
const isInViewport = rect.top < window.innerHeight + 1500 && rect.bottom > -1500;
|
||||
if (isInViewport && this.hasPreview) {
|
||||
this.isThumbnailInView = true;
|
||||
this.isInView = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up observer
|
||||
@@ -233,10 +322,15 @@ export default {
|
||||
downloadFile(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
downloadFiles(state.selected);
|
||||
if (this.updateGlobalState) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
downloadFiles(state.selected);
|
||||
} else {
|
||||
// Emit selection event for local handling
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
}
|
||||
},
|
||||
/** @param {TouchEvent} event */
|
||||
handleTouchMove(event) {
|
||||
@@ -280,10 +374,15 @@ export default {
|
||||
onRightClick(event) {
|
||||
event.preventDefault(); // Prevent default context menu
|
||||
// If one or fewer items are selected, reset the selection
|
||||
if (!state.multiple && getters.selectedCount() < 2) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
if (this.updateGlobalState) {
|
||||
if (!state.multiple && getters.selectedCount() < 2) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
}
|
||||
} else {
|
||||
// Emit selection event for local handling
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
}
|
||||
mutations.showHover({
|
||||
name: "ContextMenu",
|
||||
@@ -340,10 +439,17 @@ export default {
|
||||
},
|
||||
/** @param {DragEvent} event */
|
||||
dragStart(event) {
|
||||
if (state.selected.indexOf(this.index) === -1) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
if (this.updateGlobalState) {
|
||||
if (state.selected.indexOf(this.index) === -1) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
}
|
||||
} else {
|
||||
// Emit selection event for local handling
|
||||
if (!this.localSelected) {
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
}
|
||||
}
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData(
|
||||
@@ -520,9 +626,13 @@ export default {
|
||||
// Only reset selection if this item is not already selected
|
||||
// This prevents resetting selection when trying to open context menu on selected item
|
||||
if (!this.isSelected) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
if (this.updateGlobalState) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
} else {
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
@@ -552,67 +662,88 @@ export default {
|
||||
this.open();
|
||||
}
|
||||
|
||||
if (event.shiftKey && this.selected.length > 0) {
|
||||
let fi = 0;
|
||||
let la = 0;
|
||||
if (this.updateGlobalState) {
|
||||
if (event.shiftKey && this.selected.length > 0) {
|
||||
let fi = 0;
|
||||
let la = 0;
|
||||
|
||||
if (state.lastSelectedIndex !== null) {
|
||||
if (this.index > state.lastSelectedIndex) {
|
||||
fi = state.lastSelectedIndex;
|
||||
la = this.index;
|
||||
} else {
|
||||
fi = this.index;
|
||||
la = state.lastSelectedIndex;
|
||||
if (state.lastSelectedIndex !== null) {
|
||||
if (this.index > state.lastSelectedIndex) {
|
||||
fi = state.lastSelectedIndex;
|
||||
la = this.index;
|
||||
} else {
|
||||
fi = this.index;
|
||||
la = state.lastSelectedIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutations.resetSelected();
|
||||
|
||||
for (; fi <= la; fi++) {
|
||||
if (this.selected.indexOf(fi) === -1) {
|
||||
// @ts-ignore
|
||||
mutations.addSelected(fi);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selected.indexOf(this.index) !== -1) {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
mutations.removeSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
return;
|
||||
}
|
||||
|
||||
// In multiple selection mode, clicking an already selected item should deselect it
|
||||
if (state.multiple) {
|
||||
mutations.removeSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selected.length > 1) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!state.user.singleClick &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.shiftKey &&
|
||||
!state.multiple
|
||||
) {
|
||||
mutations.resetSelected();
|
||||
for (; fi <= la; fi++) {
|
||||
if (this.selected.indexOf(fi) === -1) {
|
||||
// @ts-ignore
|
||||
mutations.addSelected(fi);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selected.indexOf(this.index) !== -1) {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
mutations.removeSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
return;
|
||||
}
|
||||
|
||||
// In multiple selection mode, clicking an already selected item should deselect it
|
||||
if (state.multiple) {
|
||||
mutations.removeSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selected.length > 1) {
|
||||
mutations.resetSelected();
|
||||
// @ts-ignore
|
||||
mutations.addSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!state.user.singleClick &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.shiftKey &&
|
||||
!state.multiple
|
||||
) {
|
||||
mutations.resetSelected();
|
||||
}
|
||||
mutations.addSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
} else {
|
||||
// Local selection handling - emit events instead of updating global state
|
||||
if (event.shiftKey) {
|
||||
// Shift-click: select range (emit event for parent to handle)
|
||||
this.$emit('selectRange', {
|
||||
startIndex: state.lastSelectedIndex !== null ? state.lastSelectedIndex : this.index,
|
||||
endIndex: this.index
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Always toggle - parent component will handle the toggle logic
|
||||
// Just emit the index, parent will check current state and toggle
|
||||
this.$emit('select', { index: this.index });
|
||||
}
|
||||
mutations.addSelected(this.index);
|
||||
mutations.setLastSelectedIndex(this.index);
|
||||
},
|
||||
open() {
|
||||
// Don't navigate if updateGlobalState is false (component is being used as a picker/selector)
|
||||
if (!this.updateGlobalState) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if state.req.items exists and has the item at this index
|
||||
// This prevents errors when ListingItem is used outside of the main file listing (e.g., duplicate finder)
|
||||
let previousHistoryItem = null;
|
||||
@@ -623,7 +754,7 @@ export default {
|
||||
path: state.req.path,
|
||||
};
|
||||
}
|
||||
url.goToItem(this.source, this.path, previousHistoryItem);
|
||||
url.goToItem(this.source, this.path, previousHistoryItem || {});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+19
-28
@@ -1,29 +1,27 @@
|
||||
<template>
|
||||
<div class="share card">
|
||||
<div class="share__box">
|
||||
<div class="share-info">
|
||||
<div class="share-info__box">
|
||||
<div v-if="shareInfo.banner" class="banner">
|
||||
<img :src="getShareBanner" />
|
||||
<img :src="shareInfo.bannerUrl" />
|
||||
</div>
|
||||
<div v-if="shareInfo.title" class="share__box__element">
|
||||
<div v-if="shareInfo.title" class="share-info__box__element">
|
||||
<h3>{{ shareInfo.title }}</h3>
|
||||
</div>
|
||||
<div v-if="shareInfo.description" class="share__box__element">
|
||||
<div v-if="shareInfo.description" class="share-info__box__element">
|
||||
<p>{{ shareInfo.description }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<hr v-if="shareInfo.banner || shareInfo.title || shareInfo.description" />
|
||||
<div v-if="showShareInfo">
|
||||
<div class="share__box__element">
|
||||
<div class="share-info__box__element">
|
||||
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
|
||||
</div>
|
||||
<div class="share__box__element" :title="modTime">
|
||||
<div class="share-info__box__element" :title="modTime">
|
||||
<strong>{{ $t("prompts.lastModified", { suffix: ":" }) }}</strong> {{ humanTime }}
|
||||
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
<div class="share__box__element">
|
||||
<div class="share-info__box__element">
|
||||
<strong>{{ $t("prompts.size", { suffix: ":" }) }}</strong> {{ humanSize }}
|
||||
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,7 +30,6 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { publicApi } from "@/api";
|
||||
import { state, getters } from "@/store";
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { getTypeInfo } from "@/utils/mimetype";
|
||||
@@ -67,12 +64,6 @@ export default {
|
||||
}
|
||||
return state.share.passwordValid
|
||||
},
|
||||
getShareBanner() {
|
||||
if (state.shareInfo?.banner.startsWith("http")) {
|
||||
return state.shareInfo?.banner;
|
||||
}
|
||||
return publicApi.getDownloadURL(state.share, [state.shareInfo?.banner]);
|
||||
},
|
||||
shareInfo() {
|
||||
return state.shareInfo;
|
||||
},
|
||||
@@ -120,15 +111,15 @@ export default {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.share {
|
||||
.share-info {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.share__box {
|
||||
.share-info__box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.share__box__header {
|
||||
.share-info__box__header {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
@@ -137,29 +128,29 @@ export default {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.share__box__element {
|
||||
.share-info__box__element {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.share__box__element canvas {
|
||||
.share-info__box__element canvas {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.share__box__center {
|
||||
.share-info__box__center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.share__box__icon {
|
||||
.share-info__box__icon {
|
||||
font-size: 4em;
|
||||
color: #6c7b7f;
|
||||
}
|
||||
|
||||
.share__box__icon i {
|
||||
.share-info__box__icon i {
|
||||
font-size: 4em;
|
||||
}
|
||||
|
||||
.share__box__icon img,
|
||||
.share__box__icon video {
|
||||
.share-info__box__icon img,
|
||||
.share-info__box__icon video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.3em;
|
||||
@@ -175,4 +166,4 @@ export default {
|
||||
.button i {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -3,19 +3,23 @@
|
||||
<h2>{{ $t("access.accessManagement") }}</h2>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<!-- Warning banner for missing path -->
|
||||
<div v-if="!pathExists && !isEditingPath" class="warning-banner">
|
||||
<i class="material-icons">warning</i>
|
||||
<span>{{ $t("messages.pathNotFoundMessage") }}</span>
|
||||
<button class="button button--flat button--blue" @click="startPathReassignment">
|
||||
{{ $t("messages.reassignPath") }}
|
||||
</button>
|
||||
<div v-if="loading" class="loading-spinner-wrapper">
|
||||
<LoadingSpinner size="medium" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Warning banner for missing path -->
|
||||
<div v-if="!pathExists && !isEditingPath" class="warning-banner">
|
||||
<i class="material-icons">warning</i>
|
||||
<span>{{ $t("messages.pathNotFoundMessage") }}</span>
|
||||
<button class="button button--flat button--blue" @click="startPathReassignment">
|
||||
{{ $t("messages.reassignPath") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditingPath">
|
||||
<file-list @update:selected="updateTempPath" :browse-source="sourceName"></file-list>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="isEditingPath">
|
||||
<file-list @update:selected="updateTempPath" :browseSource="sourceName" :showFiles="true"></file-list>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>{{ $t("general.source", { suffix: ":" }) }} {{ currentSource }}</p>
|
||||
<div aria-label="access-path" class="searchContext clickable button" @click="startPathEdit">
|
||||
{{ $t("general.path", { suffix: ":" }) }} {{ currentPath }}
|
||||
@@ -81,7 +85,8 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<template v-if="isEditingPath">
|
||||
@@ -106,17 +111,19 @@ import { accessApi } from "@/api";
|
||||
import { mutations } from "@/store";
|
||||
import FileList from "./FileList.vue";
|
||||
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import { eventBus } from "@/store/eventBus";
|
||||
|
||||
export default {
|
||||
name: "access",
|
||||
components: { FileList, ToggleSwitch },
|
||||
components: { FileList, ToggleSwitch, LoadingSpinner },
|
||||
props: {
|
||||
sourceName: { type: String, required: true },
|
||||
path: { type: String, required: true, default: "/" }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
isEditingPath: false,
|
||||
isReassigningPath: false,
|
||||
tempPath: this.path,
|
||||
@@ -234,6 +241,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async fetchRule() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await accessApi.get(this.currentSource, this.currentPath);
|
||||
// Handle new API response structure - now sourceDenyDefault is part of the rule
|
||||
@@ -244,6 +252,8 @@ export default {
|
||||
this.rule = { denyAll: false, deny: { users: [], groups: [] }, allow: { users: [], groups: [] } };
|
||||
this.sourceDenyDefault = false;
|
||||
this.pathExists = true;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -333,4 +343,11 @@ export default {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.loading-spinner-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
</style>
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user