Compare commits

...
107 Commits
Author SHA1 Message Date
Graham Steffaniak eb518bac56 updated build for windows (#1859) 2026-01-20 09:24:18 -06:00
Graham Steffaniak d82e7f2986 updated changelog 2026-01-20 08:59:43 -06:00
Graham Steffaniak 7cd3f8374e added last minute bugfixes (#1857) 2026-01-20 08:50:40 -06:00
Graham Steffaniak 4e0f0453f3 updated sharing features (#1852) 2026-01-19 13:48:35 -06:00
Maxime Borges 98246b2981 i18n: Remove unecessary colon for API keys title (#1848) 2026-01-19 10:21:31 -06:00
Maxime Borges d2c07ffd3c settings: do not center settings title (#1849) 2026-01-19 10:21:17 -06:00
Graham SteffaniakandKevin B. 43cbadbeb8 UI improvements shelf (#1843)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-01-18 17:26:39 -06:00
Kevin B. 25dc426522 Change to plyr package directly + fixes and improvements (#1833) 2026-01-16 20:47:35 -05:00
Graham Steffaniak 27dbd509b2 Bugfix 1.2.0 (#1840) 2026-01-16 11:22:20 -06:00
Graham Steffaniak 3716c4e4c6 Change quick scan and delete purge (#1835) 2026-01-15 10:34:14 -06:00
Graham Steffaniak 7aabdadf3e Improved api format (#1834) 2026-01-14 18:21:26 -06:00
Graham Steffaniak 264de1ce88 more consistent permissions, functions (#1832) 2026-01-14 11:06:19 -06:00
Graham Steffaniak 4aae24cf11 bulk delete enhancements + duplicate detector deletion feature (#1830) 2026-01-13 11:00:33 -06:00
Graham Steffaniak 0968c44d22 updated scrolling 2026-01-12 14:21:01 -06:00
Graham Steffaniak a15fe4eaa5 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-12 12:12:14 -06:00
Graham Steffaniak a433a8a8d4 make disk check and rules more efficient (#1822) 2026-01-12 11:35:25 -06:00
Graham Steffaniak b5219ae6b6 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-12 10:46:19 -06:00
Graham SteffaniakandSmilingPie d9f91c7c65 Chunked downloading (#1821)
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
2026-01-10 11:12:32 -06:00
Kevin B. 4a85746850 Allow rename in previews (#1817) 2026-01-10 11:03:00 -06:00
Graham Steffaniak 3ae8aede25 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-09 17:31:33 -06:00
Graham Steffaniak 3990890721 Update login encoding (#1816) 2026-01-09 12:48:26 -06:00
Graham SteffaniakandSmilingPie 726116d831 Search multiple sources (#1815)
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
2026-01-08 13:35:51 -06:00
Graham Steffaniak 8274f2c715 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-08 10:07:48 -06:00
Graham SteffaniakandSmilingPie 32969d0777 Merge v1.1.6 1.2.0 (#1813)
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
2026-01-07 09:11:56 -06:00
Graham Steffaniak 8f25186136 Merge branch 'main' into dev/v1.2.0 2026-01-07 08:40:48 -06:00
Graham Steffaniak e18a73c97a two pass loading with loading bar (#1800) 2026-01-05 10:45:56 -06:00
Aleksandr Baryshnikov e5cb88ba5d feat: initial support for webdav (#1764) 2026-01-05 09:22:51 -06:00
Kevin B.andGraham Steffaniak 50b9ee8b31 make breadcrumbs act as a drop area (#1785)
Co-authored-by: Graham Steffaniak <gtsteff@gmail.com>
2026-01-04 16:45:38 -06:00
Graham Steffaniak a0e266fadf updated with more indexing changes (#1793) 2026-01-04 13:51:10 -06:00
Graham Steffaniak 377ae33e1b refactor folder size updates (#1782) 2026-01-01 18:36:35 -06:00
Kevin B. e14f2d7a4b Fix fileWatcher file selection + spacebar shortcut (#1770) 2025-12-31 17:16:52 -05:00
Graham Steffaniak fac091f1b3 Merge branch 'main' into dev/v1.2.0 2025-12-31 13:20:37 -06:00
Graham Steffaniak 5330d6b093 updated changelog 2025-12-31 13:19:19 -06:00
Graham Steffaniak 692f99251d updated with a few more small updates (#1768) 2025-12-29 16:09:07 -06:00
Graham Steffaniak 31e70519c1 updated loading spinners (#1766) 2025-12-29 11:05:34 -06:00
Graham Steffaniak 0b88a90048 Merge branch 'main' into dev/v1.2.0 2025-12-28 15:55:05 -06:00
Graham Steffaniak 79d7f07943 updated conditional rules format (#1761) 2025-12-28 15:49:58 -06:00
Graham Steffaniak b562edaaeb make indexing data persistent within cacheDir (#1760) 2025-12-28 11:30:07 -06:00
Graham Steffaniak 33ede042da efficiency tweaks for indexing (#1759) 2025-12-28 09:20:08 -06:00
Graham Steffaniak 036a0ed6c8 updated indexing functionality (#1755) 2025-12-26 16:22:02 -06:00
Graham Steffaniak 1842ff561b updated root scanner not to purge 2025-12-21 08:15:44 -06:00
Graham Steffaniak aed9f7c135 add tail/watch file watcher tool (#1738) 2025-12-20 15:40:29 -06:00
Graham Steffaniak c6600a0665 updated memory pressure (#1741) 2025-12-20 14:10:20 -06:00
Graham Steffaniak 3e3d3decc6 updated indexing design (#1728) 2025-12-16 15:14:50 -06:00
Graham Steffaniak c58b298eb5 Merge branch 'beta/v1.1.4' into dev/v1.2.0 2025-12-13 14:40:49 -06:00
Graham Steffaniak 98ba8095db updated build 2025-12-13 09:54:44 -06:00
Graham Steffaniak 68fb573059 improved cacheDir checks (#1723) 2025-12-13 08:50:05 -06:00
Graham Steffaniak 5747c40dca updated translation 2025-12-12 18:00:04 -06:00
Graham Steffaniak 598bd366db added search image show toggle (#1721) 2025-12-12 17:58:20 -06:00
Graham Steffaniak e3be3300dc File list loading spinner (#1720) 2025-12-12 15:44:27 -06:00
Graham Steffaniak 416aa75446 updated with new logger (#1719) 2025-12-12 15:24:27 -06:00
Graham Steffaniak cfb3b9e869 updated with fix for special characters (#1718) 2025-12-12 14:23:30 -06:00
Graham Steffaniak 166f1bf232 invalidate isDir cache (#1716) 2025-12-12 14:06:41 -06:00
Graham Steffaniak 451f9ac69f update with access rules for shares (#1714) 2025-12-12 12:35:25 -06:00
Graham Steffaniak b2fa1780ed updated with access rule changes (#1713) 2025-12-12 11:27:49 -06:00
Kevin B. 9255bc174b Fix/Improve some behaviors in nextPrevious (#1707) 2025-12-10 16:14:55 -06:00
Kevin B. a3a074d7b6 Fix navigation for files with special characters (#1711) 2025-12-10 14:12:21 -06:00
Claudio YanesandGraham Steffaniak aefe16b508 Implements socket activation (#1698)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2025-12-10 11:54:50 -06:00
Graham Steffaniak 728ee72e2a updated with indexing test fixes (#1699) 2025-12-03 19:33:42 -06:00
Graham Steffaniak e94075de6c updated scanner complexity 2025-12-02 11:08:28 -06:00
Graham Steffaniak c56bafb4e9 fixed some indexing issues 2025-12-02 10:58:37 -06:00
Graham Steffaniak a6e045cc85 Merge tag 'v1.1.3-beta' into experimental-sqlite-indexing 2025-12-02 09:55:22 -06:00
Graham Steffaniak 690685459e updated favicon to svg only (#1694) 2025-12-02 09:09:11 -06:00
Graham Steffaniak 6f6244b2fb revert download behavior (#1691) 2025-12-02 08:54:45 -06:00
Graham Steffaniak 05fcd31909 Clear access cache aggressively (#1690) 2025-12-01 18:28:07 -06:00
Graham Steffaniak a4107a734a continue user dir creation on error (#1689) 2025-12-01 18:06:04 -06:00
Graham Steffaniak 991be79521 scanner size increment changes (#1688) 2025-12-01 17:33:18 -06:00
Graham Steffaniak 8755e82780 improved sqlite implementation 2025-11-30 08:01:14 -06:00
Graham Steffaniak d1b3a045af updated with exclusive lock 2025-11-29 17:51:06 -06:00
Graham Steffaniak b5b365c6a7 Merge branch 'experimental-sqlite-indexing' of github.com:gtsteffaniak/filebrowser into experimental-sqlite-indexing 2025-11-29 17:46:41 -06:00
Graham Steffaniak c33a876033 improved sql indexing 2025-11-29 17:43:20 -06:00
Graham Steffaniak 42778b92bb updated with tuning 2025-11-28 19:59:15 -06:00
Graham Steffaniak ff3b66b79d adjust cache settings 2025-11-28 19:59:15 -06:00
Graham Steffaniak daedeef78d expirmental features 2025-11-28 19:59:15 -06:00
Graham Steffaniak edba1a80ef updated with a few fixes (#1678) 2025-11-28 12:08:17 -06:00
Graham Steffaniak 77fb6bdabc updated with tuning 2025-11-27 14:40:43 -06:00
Graham Steffaniak 97c37caa1c adjust cache settings 2025-11-26 18:37:39 -06:00
Graham Steffaniak 02b28284ca expirmental features 2025-11-26 18:18:12 -06:00
Graham Steffaniak 6a489c6f7c Fix sidebar custom link validation (#1665) 2025-11-26 16:27:29 -06:00
Kevin B.andGraham Steffaniak ba443398ca Improve captions styles in plyr (#1661)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2025-11-26 16:06:52 -06:00
Graham Steffaniak 17ff68c00d fix utf-8 filename prefix (#1664) 2025-11-26 16:06:34 -06:00
Graham Steffaniak cb71dd8940 fixed concurrent media duration issue (#1663) 2025-11-26 15:24:12 -06:00
Graham Steffaniak 764155d612 updated indexing changes (#1662) 2025-11-26 14:42:43 -06:00
Graham Steffaniak 9013152d9d Merge branch 'dev/v1.1.2' of github.com:gtsteffaniak/filebrowser into dev/v1.1.2 2025-11-26 09:14:09 -06:00
27bc000cda Experiment with sqllite (#1660)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-26 09:13:50 -06:00
8f4e420e73 Experiment with sqllite (#1660)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-26 09:12:09 -06:00
Graham Steffaniak e4d8a5cf20 fix editor issues (#1654) 2025-11-24 13:07:59 -06:00
Kevin B.andGraham Steffaniak fa8ceb50e6 Fix drag and drop and rows in normal view (#1651)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2025-11-24 12:12:12 -06:00
Graham Steffaniak c8073db4e6 access control download changes (#1653) 2025-11-24 10:02:39 -06:00
Graham Steffaniak 23fac83e7e adjusted list duration 2025-11-24 09:44:40 -06:00
Graham Steffaniak f36ca31c7f updated some build and comments (#1652) 2025-11-24 09:39:21 -06:00
Graham Steffaniak c0466e96fc updated translation for chinese 2025-11-24 09:05:20 -06:00
Graham Steffaniak dc753fe326 consolidate share url creation to backend api (#1647) 2025-11-24 08:18:32 -06:00
Graham Steffaniak c39a1937b4 updated with various bugfixes (#1646) 2025-11-21 19:30:37 -06:00
Graham Steffaniak 5d38b1e66a Download notification (#1645) 2025-11-21 17:31:31 -06:00
Graham Steffaniak e5e93b4b70 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 17:10:18 -06:00
eaedbbe05e Fix user notifications (#1644)
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-21 16:27:58 -06:00
Graham Steffaniak c6d5f32bd0 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 16:23:26 -06:00
Graham Steffaniak ee022bc4a9 Fix only office viewing (#1643) 2025-11-21 15:21:54 -06:00
Kevin B. daabefa6c3 Update spanish translation (#1636) 2025-11-21 15:00:28 -06:00
Graham Steffaniak 86aebde566 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 10:59:58 -06:00
Graham Steffaniak a3b0fc0038 Upgrade ffmpeg 8.0.1 (#1634) 2025-11-20 13:50:57 -06:00
dependabot[bot]andGraham Steffaniak 695364c2df Bump golang.org/x/crypto from 0.44.0 to 0.45.0 in /backend in the go_modules group across 1 directory (#1627)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-20 13:36:46 -06:00
Graham Steffaniak 58a01ff986 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-20 13:36:10 -06:00
Graham Steffaniak 3298111560 Add exif tool (#1625) 2025-11-19 17:54:48 -06:00
Graham Steffaniak 3ca9f54de0 updated with compile safe i18n checks (#1623) 2025-11-19 16:48:44 -06:00
Graham Steffaniak 97d16d1663 add exiftool for orientation 2025-11-19 16:46:47 -06:00
181 changed files with 19454 additions and 6691 deletions

No files matched your search

+1
View File
@@ -27,6 +27,7 @@ frontend/tsconfig.tsbuildinfo
/backend/srv
/backend/http/dist
/backend/http/embed
/backend/debug
.DS_Store
node_modules
+46
View File
@@ -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**:
+1 -1
View File
@@ -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
+15 -10
View File
@@ -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'
+1
View File
@@ -3,3 +3,4 @@ air.log
filebrowser
generated.yaml
test_config.yaml
/data
+99 -30
View File
@@ -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
View File
@@ -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.
-108
View 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
View File
@@ -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.")
}
+60
View File
@@ -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
}
+102 -29
View File
@@ -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:]
}
+45 -151
View File
@@ -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
},
})
}
+1 -1
View File
@@ -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
},
},
+59 -40
View File
@@ -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 {
+17 -2
View File
@@ -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
}
+15
View File
@@ -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 "/"
+148
View File
@@ -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
}
+17 -2
View File
@@ -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) {
+2 -1
View File
@@ -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 {
+34
View File
@@ -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"`
}
+169
View File
@@ -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
}
+4 -1
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
//go:build cgosql
// +build cgosql
package sql
import (
_ "github.com/mattn/go-sqlite3"
)
const SqliteDriver = "sqlite3"
+10
View File
@@ -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
+103
View File
@@ -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
View File
@@ -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.
+3
View 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
}
+55
View File
@@ -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
}
+3 -3
View File
@@ -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")
+156
View File
@@ -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
}
+3 -1
View File
@@ -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
}
+87 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+512
View File
@@ -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})
}
+11 -2
View File
@@ -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)
+80 -7
View File
@@ -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
+1 -2
View File
@@ -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)
+2 -3
View File
@@ -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
View File
@@ -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 {
+15 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -3
View File
@@ -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.
+81
View File
@@ -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
}
+133 -84
View File
@@ -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)
}
}
+160 -67
View File
@@ -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)")
}
}
+325
View File
@@ -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 }
File diff suppressed because it is too large. Load diff
+148 -227
View File
@@ -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
View File
@@ -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)
}
}
+122 -137
View File
@@ -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))
+20 -12
View File
@@ -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)
}
+1
View File
@@ -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:"-"`
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
}
+27 -5
View File
@@ -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.
+20 -5
View File
@@ -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")
}
-17
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
go test -race -v -coverpkg=./... -coverprofile=coverage.cov ./...
go tool cover -html=coverage.cov
-2
View File
@@ -1,2 +0,0 @@
#!/bin/bash
for i in $(find $(pwd) -name '*.go');do gofmt -w $i;done
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+574 -66
View File
@@ -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:
+4 -18
View File
@@ -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",
+1 -1
View File
@@ -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",
+9 -18
View File
@@ -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
View File
@@ -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);
}
}
+3 -3
View File
@@ -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();
}
});
},
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+22 -7
View File
@@ -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;
-201
View File
@@ -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>
+27 -1
View File
@@ -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";
},
+300
View File
@@ -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>
+1 -1
View File
@@ -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;
+47 -18
View File
@@ -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>
+230
View File
@@ -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>
+1 -1
View File
@@ -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>
+217 -86
View File
@@ -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 || {});
},
},
};
@@ -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>
+30 -13
View File
@@ -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