Compare commits

...
5 Commits
23 changed files with 182 additions and 252 deletions

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
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.1.3-beta
**Notes**:
- continue create user dir scope even if filesystem path creation fails #1509
- access control cache is cleared more aggresively to ensure no delay
- removed default .ico favicon in favor of .svg
**BugFixes**:
- indexing used size increases over time #1685
- download progress is not shown #1687
## v1.1.2-beta
**Notes**:
+13 -6
View File
@@ -8,6 +8,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// MakeUserDir makes the user directory according to settings.
@@ -26,7 +27,8 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
for i, scope := range u.Scopes {
source, ok := settings.Config.Server.SourceMap[scope.Name]
if !ok {
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
continue
}
// create directory and append user name
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
@@ -35,31 +37,36 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
// If parent directory doesn't exist and createUserDir is enabled, create it
if !Exists(parentDir) {
if err := MakeUserDir(parentDir); err != nil {
return fmt.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
continue
}
}
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
// create directory exactly as specified
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if !Exists(parentDir) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
continue
}
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: failed to create user home dir: %s", err)
logger.Errorf("create user: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else {
// just assigning scope to path provided, so just check that it exists
path := filepath.Join(source.Path, scope.Scope)
if !Exists(path) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
continue
}
}
u.Scopes[i] = scope
-4
View File
@@ -17,9 +17,6 @@ func validateAccessRules() {
}
// Get all sources
for sourcePath := range settings.Config.Server.SourceMap {
// Clear cache before migration to ensure we get fresh data
store.Access.ClearCacheForSource(sourcePath)
// Get all rules for this source
rules, err := store.Access.GetAllRules(sourcePath)
if err != nil {
@@ -65,7 +62,6 @@ func validateAccessRules() {
// After migration, clear cache
if migratedCount > 0 {
store.Access.ClearCacheForSource(sourcePath)
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
}
}
+1 -1
View File
@@ -643,7 +643,7 @@ func loadCustomFavicon() {
allowedFormats := []string{".ico", ".png", ".svg"}
// Set default embedded favicon path
Env.FaviconEmbeddedPath = "img/icons/favicon.ico"
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
// Check if a custom favicon path is configured
if Config.Frontend.Favicon == "" {
+27 -103
View File
@@ -153,18 +153,12 @@ func ClearCache() {
rulesCache = cache.NewCache[map[string]FrontendAccessRule](1 * time.Minute)
}
// ClearCacheForSource clears the cache for a specific source
func (s *Storage) ClearCacheForSource(sourcePath string) {
// Clear all caches related to this source
accessCache.Delete(accessChangedKey + sourcePath)
rulesCache.Delete(accessChangedKey + sourcePath)
versionCache.Delete("version:" + sourcePath)
// Clear all permission caches for this source
// We need to iterate through all permission cache keys and remove those for this source
// Since we can't iterate through cache keys, we'll just increment the version
// which will invalidate all permission caches for this source
s.incrementSourceVersion(sourcePath)
// clearAllCaches clears ALL caches. This should be called whenever rules are created, updated, or deleted.
func (s *Storage) clearAllCaches() {
accessCache.ClearAll()
versionCache.ClearAll()
permissionCache.ClearAll()
rulesCache.ClearAll()
}
// RemoveRuleByPath removes a rule by its exact path from the internal storage
@@ -180,18 +174,11 @@ func (s *Storage) RemoveRuleByPath(sourcePath, indexPath string) {
// Remove the rule by exact path match (don't normalize)
if _, exists := rulesBySource[indexPath]; exists {
delete(rulesBySource, indexPath)
// If no rules left for this source, remove the source entry
if len(rulesBySource) == 0 {
delete(s.AllRules, sourcePath)
}
// Invalidate caches
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
// Save to database
s.clearAllCaches()
err := s.SaveToDB()
if err != nil {
logger.Errorf("error saving access rules to database: %v", err)
@@ -233,9 +220,7 @@ func (s *Storage) DenyUser(sourcePath, indexPath, username string) error {
return errors.ErrExist
}
rule.Deny.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return s.SaveToDB()
}
@@ -254,9 +239,7 @@ func (s *Storage) AllowUser(sourcePath, indexPath, username string) error {
return errors.ErrExist
}
rule.Allow.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return s.SaveToDB()
}
@@ -273,9 +256,7 @@ func (s *Storage) DenyGroup(sourcePath, indexPath, groupname string) error {
return errors.ErrExist
}
rule.Deny.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return s.SaveToDB()
}
@@ -292,9 +273,7 @@ func (s *Storage) AllowGroup(sourcePath, indexPath, groupname string) error {
return errors.ErrExist
}
rule.Allow.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return s.SaveToDB()
}
@@ -307,9 +286,7 @@ func (s *Storage) DenyAll(sourcePath, indexPath string) error {
return errors.ErrExist
}
rule.DenyAll = true
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return s.SaveToDB()
}
@@ -659,11 +636,7 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
if exists {
delete(rule.Allow.Users, username)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
removed := exists
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
@@ -672,8 +645,7 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return exists, s.SaveToDB()
}
return false, nil
@@ -692,11 +664,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
if exists {
delete(rule.Allow.Groups, groupname)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
removed := exists
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
@@ -705,8 +673,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return exists, s.SaveToDB()
}
return exists, nil
@@ -725,11 +692,7 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
if exists {
delete(rule.Deny.Users, username)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
removed := exists
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
@@ -738,8 +701,7 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return exists, s.SaveToDB()
}
return false, nil
@@ -758,11 +720,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
if exists {
delete(rule.Deny.Groups, groupname)
}
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
removed := exists
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
@@ -771,8 +729,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return exists, s.SaveToDB()
}
return exists, nil
@@ -790,7 +747,6 @@ func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
removed := false
if rule.DenyAll {
rule.DenyAll = false
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
@@ -801,8 +757,7 @@ func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return true, s.SaveToDB()
}
return false, nil
@@ -834,12 +789,8 @@ func (s *Storage) RemoveAllRulesForUser(username string) error {
}
}
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, "false")
rulesCache.Delete(accessChangedKey + sp)
}
if changed {
s.clearAllCaches()
return s.SaveToDB()
}
return nil
@@ -871,12 +822,8 @@ func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
}
}
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, "false")
rulesCache.Delete(accessChangedKey + sp)
}
if changed {
s.clearAllCaches()
return s.SaveToDB()
}
return nil
@@ -1084,16 +1031,6 @@ func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]F
return allGroupRules
}
// incrementSourceVersion increments the version of a sourcePath to invalidate caches.
func (s *Storage) incrementSourceVersion(sourcePath string) {
key := "version:" + sourcePath
version := 0
if v, ok := versionCache.Get(key); ok {
version = v
}
versionCache.Set(key, version+1)
}
// HasAnyVisibleItems checks if a user has access to any items in a given parent path.
// This is used to determine if a user should see a folder's contents even when
// they don't have direct access to the parent folder.
@@ -1164,9 +1101,7 @@ func (s *Storage) RemoveUserCascade(sourcePath, indexPath, username string, allo
}
if changed {
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return removedCount, s.SaveToDB()
}
@@ -1223,9 +1158,7 @@ func (s *Storage) RemoveGroupCascade(sourcePath, indexPath, groupname string, al
}
if changed {
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
return removedCount, s.SaveToDB()
}
@@ -1272,11 +1205,7 @@ func (s *Storage) UpdateRules(sourcePath, oldPath, newPath string) (int, error)
}
if updated > 0 {
// Invalidate caches
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
if err := s.SaveToDB(); err != nil {
return updated, err
}
@@ -1307,12 +1236,7 @@ func (s *Storage) UpdateRulePath(sourcePath, oldPath, newPath string) error {
// Remove the old rule and add it with the new path
delete(rulesBySource, oldPath)
rulesBySource[newPath] = rule
// Invalidate caches
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
s.clearAllCaches()
logger.Debugf("access rule path updated: source=%s, fromPath=%s, toPath=%s", sourcePath, oldPath, newPath)
return s.SaveToDB()
}
+1 -1
View File
@@ -117,7 +117,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, true)
if err != nil {
return err
logger.Error(err.Error())
}
}
// converting scopes to map of paths intead of names (names can change)
+1 -1
View File
@@ -12,7 +12,7 @@ require (
github.com/goccy/go-yaml v1.18.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.0.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v0.2.3
github.com/kovidgoyal/imaging v1.7.2
github.com/pquerna/otp v1.5.0
+2 -2
View File
@@ -490,8 +490,8 @@ github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduw
github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M=
github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8=
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
github.com/gtsteffaniak/go-cache v1.0.0 h1:CuZw6eI/be6Oi3oR94Jb5LhcZShMCs6+1wvS7WGN8oQ=
github.com/gtsteffaniak/go-cache v1.0.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
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/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
+1 -1
View File
@@ -189,7 +189,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicRoutes.Handle("GET /static/", http.HandlerFunc(staticAssetHandler))
// Standard browser favicon and manifest routes
router.HandleFunc("GET /favicon.ico", http.HandlerFunc(staticAssetHandler))
router.HandleFunc("GET /favicon.svg", http.HandlerFunc(staticAssetHandler))
router.HandleFunc("GET /site.webmanifest", http.HandlerFunc(staticAssetHandler))
router.HandleFunc("GET /manifest.json", http.HandlerFunc(staticAssetHandler))
+1 -1
View File
@@ -210,7 +210,7 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
// Handle special routes that need path mapping
var assetPath string
switch path {
case "favicon.ico", "favicon":
case "favicon.svg", "favicon":
// Handle custom favicon from filesystem
if settings.Env.FaviconIsCustom {
http.ServeFile(w, r, settings.Env.FaviconPath)
+2 -3
View File
@@ -236,9 +236,8 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
if !dirInfo.IsDir() {
// Use handleFile for consistent size calculation across platforms
// API calls (GetFsDirInfo) should not update totalSize, so pass false for isRoutineScan
realSize, _ := idx.handleFile(dirInfo, adjustedPath, realPath, false)
realSize, _ := idx.handleFile(dirInfo, adjustedPath, realPath)
size := int64(realSize)
fileInfo := iteminfo.FileInfo{
Path: adjustedPath,
ItemInfo: iteminfo.ItemInfo{
@@ -365,7 +364,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
}
} else {
realFilePath := realPath + "/" + file.Name()
size, shouldCountSize := idx.handleFile(file, fullCombined, realFilePath, config.IsRoutineScan)
size, shouldCountSize := idx.handleFile(file, fullCombined, realFilePath)
itemInfo.DetectType(realFilePath, false)
usedCachedPreview := false
if !idx.Config.DisableIndexing && config.Recursive {
+63 -34
View File
@@ -22,9 +22,14 @@ type Scanner struct {
lastScanned time.Time
quickScanTime int
fullScanTime int
numDirs uint64 // Local count for this path
numFiles uint64 // Local count for this path
scannerSize uint64 // Size contributed by this scanner (for delta calculation)
// 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)
// Reference back to parent index
idx *Index
@@ -121,31 +126,17 @@ func (s *Scanner) runRootScan(quick bool) {
IsRoutineScan: true,
}
// Reset counters for full scan
// 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
}
// Track size before scan for delta calculation
previousScannerSize := s.scannerSize
s.idx.mu.Lock()
if previousScannerSize > 0 {
if s.idx.totalSize >= previousScannerSize {
s.idx.totalSize -= previousScannerSize
} else {
// Safety check: if totalSize is less than previousScannerSize, something is wrong
// Reset to 0 to avoid underflow
logger.Warningf("[%s] Scanner [%s] WARNING: totalSize (%d) < previousScannerSize (%d), resetting totalSize to 0", s.idx.Name, s.scanPath, s.idx.totalSize, previousScannerSize)
s.idx.totalSize = 0
}
}
indexSizeBefore := s.idx.totalSize
s.idx.mu.Unlock()
logger.Debugf("[%s] Scanner [%s] START: indexSizeBefore=%d, previousScannerSize=%d (quick=%v)", s.idx.Name, s.scanPath, indexSizeBefore, previousScannerSize, quick)
s.filesChanged = false
startTime := time.Now()
@@ -154,12 +145,30 @@ func (s *Scanner) runRootScan(quick bool) {
logger.Errorf("Root scanner error: %v", err)
}
// Calculate delta for this scanner
// Root scanner gets values directly from index metadata - simple!
s.idx.mu.RLock()
indexSizeAfter := s.idx.totalSize
rootDirInfo, exists := s.idx.Directories["/"]
s.idx.mu.RUnlock()
newScannerSize := indexSizeAfter - indexSizeBefore
s.scannerSize = newScannerSize
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++
}
}
// 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
@@ -178,26 +187,46 @@ func (s *Scanner) runChildScan(quick bool) {
IsRoutineScan: true,
}
// Reset counters for full scan
// 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
}
s.idx.mu.RLock()
indexSizeBefore := s.idx.totalSize
s.idx.mu.RUnlock()
s.filesChanged = false
startTime := time.Now()
err := s.idx.indexDirectory(s.scanPath, config)
if err != nil {
logger.Errorf("Scanner [%s] error: %v", s.scanPath, err)
}
// Calculate new values after scan
newNumDirs := s.numDirs
newNumFiles := s.numFiles
// 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()
indexSizeAfter := s.idx.totalSize
dirInfo, exists := s.idx.Directories[s.scanPath]
s.idx.mu.RUnlock()
newScannerSize := indexSizeAfter - indexSizeBefore
s.scannerSize = newScannerSize
newsize := prevSize
if exists && dirInfo != nil {
newsize = uint64(dirInfo.Size)
}
// 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
+7 -1
View File
@@ -178,7 +178,7 @@ func (idx *Index) SendSourceUpdateEvent() error {
logger.Debug("Skipping source update event for mock index.")
return nil
}
reducedIndex, err := GetIndexInfo(idx.Name, false)
reducedIndex, err := GetIndexInfo(idx.Name, true)
if err != nil {
logger.Errorf("[%s] Error getting index info: %v", idx.Name, err)
return err
@@ -284,6 +284,7 @@ func (idx *Index) aggregateStatsFromScanners() {
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
@@ -292,6 +293,7 @@ func (idx *Index) aggregateStatsFromScanners() {
totalFiles += scanner.numFiles
totalQuickScanTime += scanner.quickScanTime
totalFullScanTime += scanner.fullScanTime
totalSizeFromScanners += scanner.size
if scanner.lastScanned.After(mostRecentScan) {
mostRecentScan = scanner.lastScanned
}
@@ -299,6 +301,10 @@ func (idx *Index) aggregateStatsFromScanners() {
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
+2 -16
View File
@@ -62,8 +62,7 @@ func getFileDetails(sys any, filePath string) (uint64, uint64, uint64, bool) {
// 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)
// isRoutineScan: if true, updates the global totalSize; if false (API calls), only returns size
func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath string, isRoutineScan bool) (size uint64, shouldCountSize bool) {
func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath string) (size uint64, shouldCountSize bool) {
var realSize uint64
var nlink uint64
var ino uint64
@@ -88,21 +87,8 @@ func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath
// First time seeing this inode.
idx.processedInodes[ino] = struct{}{}
idx.FoundHardLinks[fullCombined] = realSize
// Only update totalSize during routine scans (not API calls)
if isRoutineScan {
idx.totalSize += realSize
}
return realSize, true // Count size for directory total.
}
// It's a regular file.
// Only update totalSize during routine scans (not API calls)
if isRoutineScan {
idx.mu.Lock()
idx.totalSize += realSize
idx.mu.Unlock()
}
return realSize, true // Count size.
return realSize, true // Count size for directory total.
}
// platform specific rules
+1 -18
View File
@@ -47,28 +47,11 @@ func getPartitionSize(path string) (uint64, error) {
return totalBytes, nil
}
func getFileDetails(sys any, filePath string) (uint64, uint64, uint64, bool) {
// On Windows, os.FileInfo.Sys() returns *syscall.Win32FileAttributeData,
// which does not contain inode or link count information.
// We return false to indicate that we should use the fallback mechanism.
// filePath parameter is ignored on Windows
return 0, 1, 0, false
}
// 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)
// isRoutineScan: if true, updates the global totalSize; if false (API calls), only returns size
func (idx *Index) handleFile(file os.FileInfo, fullCombined string, realFilePath string, isRoutineScan bool) (size uint64, shouldCountSize bool) {
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())
// Windows doesn't support hard links in the same way, so always count size
// Only update totalSize during routine scans (not API calls)
if isRoutineScan {
idx.mu.Lock()
idx.totalSize += realSize
idx.mu.Unlock()
}
return realSize, true
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

+14 -49
View File
@@ -98,61 +98,26 @@ export async function download(format, files, shareHash = "") {
files: fileargs,
algo: format,
hash: shareHash,
...(state.share.token && { token: state.share.token })
...(state.share.token && { token: state.share.token }),
sessionId: state.sessionId
})
const url = window.origin + apiPath
// Use fetch to capture errors in the console
const response = await fetch(url, {
credentials: 'same-origin',
headers: {
"sessionId": state.sessionId,
},
})
if (!response.ok) {
const errorText = await response.text()
const error = new Error(`Download failed: ${response.status} ${response.statusText}`)
console.error('Download API error:', {
status: response.status,
statusText: response.statusText,
body: errorText,
url: url
})
throw error
}
// Get the blob from the response
const blob = await response.blob()
// Determine filename from Content-Disposition header or use default
let filename = 'download'
const contentDisposition = response.headers.get('Content-Disposition')
if (contentDisposition) {
const utf8 = contentDisposition.split("filename*=utf-8''")[1]
if (utf8) {
filename = decodeURIComponent(utf8.split(';')[0].trim())
} else {
const ascii = contentDisposition.split('filename="')[1]
if (ascii) {
filename = ascii.split('"')[0]
}
}
} else {
filename = `download.${format}`
}
// Create a blob URL and trigger download
const blobUrl = window.URL.createObjectURL(blob)
// Create a direct link and trigger the download
// This allows the browser to handle the download natively with:
// - Native download progress indicator
// - Shows up in browser's download menu
// - Doesn't load entire file into memory first
const link = document.createElement('a')
link.href = blobUrl
link.setAttribute('download', filename)
link.href = url
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// Clean up the blob URL
window.URL.revokeObjectURL(blobUrl)
// Clean up after a short delay
setTimeout(() => {
document.body.removeChild(link)
}, 100)
}
export function post(
+3 -3
View File
@@ -23,10 +23,10 @@
<button
class="button button--flat"
@click="submit"
:aria-label="$t('general.submit')"
:title="$t('general.submit')"
:aria-label="$t('general.login')"
:title="$t('general.login')"
>
{{ $t("general.submit") }}
{{ $t("general.login") }}
</button>
</div>
</template>
+3 -3
View File
@@ -7,14 +7,14 @@
<p>{{ $t("prompts.renameMessage") }}</p>
<div v-if="item.type !== 'directory'" class="filename-inputs">
<input class="input filename-input" :class="{ 'form-invalid': !validation.valid }" v-focus type="text" @keyup.enter="submit"
<input aria-label="New Name" class="input filename-input" :class="{ 'form-invalid': !validation.valid }" v-focus type="text" @keyup.enter="submit"
v-model.trim="fileName" @input="updateFullName" />
<span class="extension-separator">.</span> <!--eslint-disable-line @intlify/vue-i18n/no-raw-text-->
<input class="input extension-input" type="text" @keyup.enter="submit" v-model.trim="fileExtension"
@input="updateFullName" />
</div>
<input v-else class="input" :class="{ 'form-invalid': !validation.valid }" v-focus type="text" @keyup.enter="submit"
<input v-else class="input" aria-label="New Name" :class="{ 'form-invalid': !validation.valid }" v-focus type="text" @keyup.enter="submit"
v-model.trim="name" />
<p v-if="!validation.valid && name.length > 0" class="validation-error">
<span v-if="validation.reason === 'conflict'">
@@ -32,7 +32,7 @@
{{ $t("general.cancel") }}
</button>
<button @click="submit" class="button button--flat" :disabled="!canRename"
type="submit" :aria-label="$t('general.rename')" :title="$t('general.rename')">
type="submit" :aria-label="$t('general.submit')" :title="$t('general.submit')">
{{ $t("general.rename") }}
</button>
</div>
+1 -1
View File
@@ -67,7 +67,7 @@
"scope": "Scope{suffix}",
"rules": "Rules{suffix}",
"downloads": "Downloads{suffix}",
"submit": "Login{suffix}",
"submit": "Submit{suffix}",
"navigation": "Navigation{suffix}",
"metadata": "Metadata{suffix}",
"loading": "Loading{suffix}",
+1 -1
View File
@@ -48,7 +48,7 @@
<div v-if="globalVars.recaptcha" id="globalVars.recaptcha"></div>
<input class="button button--block" type="submit"
:value="createMode ? $t('general.signup') : $t('general.submit')" />
:value="createMode ? $t('general.signup') : $t('general.login')" />
<p @click="toggleMode" v-if="signup" aria-label="sign up toggle">
{{ createMode ? $t("login.loginInstead") : $t("login.createAnAccount") }}
</p>
Whitespace-only changes.
@@ -18,7 +18,7 @@ test("info from search", async({ page, checkForErrors, context }) => {
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('#search').click()
await page.locator('#main-input').fill('file.tar.gz');
await expect(page.locator('#result-list')).toHaveCount(1);
await expect(page.locator('#result-list ul li.search-entry')).toHaveCount(1);
await page.locator('li[aria-label="file.tar.gz"]').click({ button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1');
@@ -32,7 +32,7 @@ test("open from search", async({ page, checkForErrors, context }) => {
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('#search').click()
await page.locator('#main-input').fill('file.tar.gz');
await expect(page.locator('#result-list')).toHaveCount(1);
await expect(page.locator('#result-list ul li.search-entry')).toHaveCount(1);
await page.locator('li[aria-label="file.tar.gz"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - file.tar.gz");
await expect(page.locator('#previewer')).toContainText('Preview is not available for this file.');
@@ -99,8 +99,12 @@ test("delete file", async({ page, checkForErrors, context }) => {
await expect(page.locator('div[aria-label="delete-path"]')).toHaveText('/deleteme.txt');
await page.locator('button[aria-label="Confirm-Delete"]').click();
await checkForNotification(page, "Deleted successfully!");
checkForErrors();
// verify its no longer in index via search
await page.locator('#search').click()
await page.locator('#main-input').fill('deleteme.txt');
await expect(page.locator('#result-list ul li.search-entry')).toHaveCount(0);
checkForErrors();
})
test("delete nested file prompt", async({ page, checkForErrors, context }) => {
@@ -115,3 +119,23 @@ test("delete nested file prompt", async({ page, checkForErrors, context }) => {
await expect(page.locator('div[aria-label="delete-path"]')).toHaveText('/folder#hash/file#.sh');
checkForErrors();
})
test("rename file", async({ page, checkForErrors, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="renameme.txt"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="renameme.txt"]').click({ button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1');
await page.locator('button[aria-label="Rename"]').click();
await page.locator('input[aria-label="New Name"]').waitFor({ state: 'visible' });
await page.locator('input[aria-label="New Name"]').fill('renamed.txt');
await page.locator('button[aria-label="Submit"]').click();
await checkForNotification(page, "Item renamed successfully!");
// verify its no longer in index via search
await page.locator('#search').click()
await page.locator('#main-input').fill('renameme.txt');
await expect(page.locator('#result-list ul li.search-entry')).toHaveCount(0);
checkForErrors();
})