Compare commits

...
2 Commits
Author SHA1 Message Date
Graham Steffaniak c49022ea3e chunked downloads issue (#2390) 2026-05-05 16:46:26 -05:00
Graham Steffaniak 64e6910984 updated with index changes (#2388) 2026-05-04 15:51:03 -05:00
29 changed files with 849 additions and 279 deletions

No files matched your search

+9
View File
@@ -2,6 +2,15 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.3.10
**Notes**:
- When indexing is disabled for a source, the usage will always reported as partition size.
- removed deprecated `source.config.disableIndexing`, see [rules](https://filebrowserquantum.com/en/docs/advanced/source-configuration/conditional-rules/#disable-indexing)
**BugFixes**:
- Disable index option not working (#2385)
## v1.3.9
**Security**:
@@ -11,5 +11,7 @@ server-main: &server-main
- path: "."
name: "docker"
config:
disableIndexing: true
defaultEnabled: true
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
+3 -1
View File
@@ -11,8 +11,10 @@ server:
- path: "."
name: "docker"
config:
disableIndexing: true
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
- path: "/tests/playwright-files"
name: "access"
config:
+6 -6
View File
@@ -445,7 +445,7 @@ func DeleteFiles(source, absPath string, isDir bool) error {
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
}
if !index.Config.DisableIndexing {
if !index.Config.ResolvedRules.IndexingDisabled {
indexPath := index.MakeIndexPath(absPath, isDir)
// Perform the physical deletion
@@ -482,7 +482,7 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
if idx == nil {
return fmt.Errorf("could not get index: %v ", source)
}
if idx.Config.DisableIndexing {
if idx.Config.ResolvedRules.IndexingDisabled {
return nil
}
// Always normalize path using MakeIndexPath
@@ -590,7 +590,7 @@ 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 !srcIdx.Config.ResolvedRules.IndexingDisabled {
go func() {
if isSrcDir {
srcIdx.DeleteMetadata(srcIndexPath, true, true)
@@ -605,7 +605,7 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
// Handle DESTINATION indexing
// Run async to avoid blocking the HTTP response
if !dstIdx.Config.DisableIndexing {
if !dstIdx.Config.ResolvedRules.IndexingDisabled {
if isSrcDir {
go func() {
// Recursively index the moved directory tree
@@ -663,7 +663,7 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
// Refresh source (non-recursive, just metadata)
srcIdx := indexing.GetIndex(sourceIndex)
if srcIdx != nil && !srcIdx.Config.DisableIndexing {
if srcIdx != nil && !srcIdx.Config.ResolvedRules.IndexingDisabled {
srcRefreshPath := realsrc
if !isSrcDir {
srcRefreshPath = filepath.Dir(realsrc)
@@ -674,7 +674,7 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
// 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 dstIdx != nil && !dstIdx.Config.ResolvedRules.IndexingDisabled {
if isSrcDir {
go func() {
// Recursively index the copied directory tree
+19
View File
@@ -240,3 +240,22 @@ func ClearCacheDir(cacheDir string) {
}
}
// ClearDirectoryContents removes all files and subdirectories inside dir; dir itself remains.
// If dir does not exist, returns nil.
func ClearDirectoryContents(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if err := os.RemoveAll(path); err != nil {
return err
}
}
return nil
}
+3
View File
@@ -176,6 +176,9 @@ func StartFilebrowser() {
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
}
<-shutdownComplete
if err := fileutils.ClearDirectoryContents(settings.DownloadCacheDir()); err != nil {
logger.Warningf("failed to clear download spool on shutdown: %v", err)
}
logger.Info("Shutdown complete.")
}
+17
View File
@@ -98,6 +98,10 @@ func setupFs() {
// Perform mandatory cache directory speed test
testCacheDirSpeed()
if err := PrepareDownloadSpoolDir(); err != nil {
logger.Fatalf("cacheDir failed to prepare download spool: %v", err)
}
logger.Infof("cache directory setup successfully: %v", Config.Server.CacheDir)
}
@@ -826,6 +830,19 @@ func PWAIconsCacheDir() string {
return filepath.Join(Config.Server.CacheDir, "icons")
}
// Where transient archives for multi-request (Range) downloads are stored to support chunked downloads.
func DownloadCacheDir() string {
return filepath.Join(Config.Server.CacheDir, "downloads")
}
// PrepareDownloadSpoolDir creates the download spool directory and removes any leftover dl-archive-*
func PrepareDownloadSpoolDir() error {
if err := os.MkdirAll(DownloadCacheDir(), fileutils.PermDir); err != nil {
return err
}
return fileutils.ClearDirectoryContents(DownloadCacheDir())
}
func loadLoginIcon() {
const imageName = "login icon"
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
+7
View File
@@ -133,6 +133,13 @@ func ApplyUserDefaults(u *users.User) {
Name: source.Path, // backend name is path
Scope: source.Config.DefaultUserScope,
})
u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{
Name: source.Name,
Category: "source",
Target: "/",
Icon: "",
SourceName: source.Path,
})
}
}
}
-1
View File
@@ -205,7 +205,6 @@ type SourceConfig struct {
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"` // 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
-7
View File
@@ -93,13 +93,6 @@ func quickSetup(store *bolt.BoltStore) {
}
user.Password = settings.Config.Auth.AdminPassword
user.Permissions.Admin = true
user.Scopes = []users.SourceScope{}
for _, val := range settings.Config.Server.Sources {
user.Scopes = append(user.Scopes, users.SourceScope{
Name: val.Path, // backend name is path
Scope: "",
})
}
user.LockPassword = false
user.Permissions = settings.AdminPerms()
user.ShowFirstLogin = settings.Env.IsFirstLoad && user.Permissions.Admin
+7 -30
View File
@@ -105,10 +105,15 @@ func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
}
// Validate source exists
sourceInfo, ok := sourceConfig.GetSourceByName(link.SourceName)
if !ok {
sourceInfo2, ok2 := sourceConfig.GetSourceByPath(link.SourceName)
if !ok && !ok2 {
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
if ok {
link.SourceName = sourceInfo.Path
} else {
link.SourceName = sourceInfo2.Path
}
}
// Store the link as-is with all fields preserved
newLinks = append(newLinks, link)
@@ -116,34 +121,6 @@ func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
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 strings.HasPrefix(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, "/") {
+283 -22
View File
@@ -4,6 +4,8 @@ import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -14,17 +16,163 @@ import (
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"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/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/time/rate"
)
// archiveMultiRequestIdle is how long without another Range/HEAD on the same archiveToken before
// the spooled file is removed (abandoned chunked download). Each chunk request extends this window.
const archiveMultiRequestIdle = 5 * time.Minute
// archiveSpoolPathCache maps archiveToken -> temp file path on disk for chunked archive downloads.
var archiveSpoolPathCache = cache.NewCache[string](archiveMultiRequestIdle)
// archiveSpoolIdleTimers implements a sliding idle deadline per token so temp files are deleted
// after abandonment; the in-process cache alone does not remove on-disk spool files.
var (
archiveSpoolIdleMu sync.Mutex
archiveSpoolIdleTimers = make(map[string]*time.Timer) // guarded by archiveSpoolIdleMu
)
func randomArchiveToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func stopArchiveSpoolIdleTimer(token string) {
archiveSpoolIdleMu.Lock()
t, ok := archiveSpoolIdleTimers[token]
if ok {
delete(archiveSpoolIdleTimers, token)
}
archiveSpoolIdleMu.Unlock()
if !ok {
return
}
if !t.Stop() {
select {
case <-t.C:
default:
}
}
}
// removeSpooledArchiveNow drops token state and deletes the temp file (idle timeout or final chunk served).
func removeSpooledArchiveNow(token, tmpPath string) {
stopArchiveSpoolIdleTimer(token)
archiveSpoolPathCache.Delete(token)
if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) {
logger.Debugf("archive spool remove %s: %v", tmpPath, err)
}
}
func rescheduleArchiveSpoolIdleCleanup(token, tmpPath string) {
stopArchiveSpoolIdleTimer(token)
timer := time.AfterFunc(archiveMultiRequestIdle, func() {
removeSpooledArchiveNow(token, tmpPath)
})
archiveSpoolIdleMu.Lock()
archiveSpoolIdleTimers[token] = timer
archiveSpoolIdleMu.Unlock()
}
// archiveGetDeliversThroughEOF reports whether this GET serves through the last byte (full body or Range that ends at size-1).
func archiveGetDeliversThroughEOF(r *http.Request, size int64) bool {
if r.Method != http.MethodGet || size <= 0 {
return false
}
rg := r.Header.Get("Range")
if rg == "" {
return true
}
const prefix = "bytes="
if !strings.HasPrefix(rg, prefix) {
return false
}
rg = strings.TrimSpace(rg[len(prefix):])
if i := strings.IndexByte(rg, ','); i >= 0 {
rg = rg[:i]
}
dash := strings.IndexByte(rg, '-')
if dash < 0 {
return false
}
startStr := strings.TrimSpace(rg[:dash])
endStr := strings.TrimSpace(rg[dash+1:])
if endStr == "" {
// "bytes=N-": suffix through EOF
return true
}
if startStr == "" && endStr != "" {
// "bytes=-N": last-N suffix includes EOF when size > 0
return true
}
end, err := strconv.ParseInt(endStr, 10, 64)
if err != nil {
return false
}
return end >= size-1
}
// archiveAttachmentStem is the filename stem (no extension) for Content-Disposition on multi-item /
// directory downloads. It prefers the client file path so names match the UI (e.g. "Desktop.zip"); the
// old filepath.Dir(realPath)+Base trick can yield "/" at filesystem root or the wrong parent if index metadata lags.
func archiveAttachmentStem(fileList []string, realPath string) string {
if len(fileList) == 0 {
return "download"
}
first := filepath.ToSlash(strings.TrimSuffix(strings.TrimSpace(fileList[0]), "/"))
var stem string
if len(fileList) == 1 {
stem = path.Base(first)
} else {
stem = path.Base(path.Dir(first))
}
if stem == "" || stem == "." || stem == "/" {
if len(fileList) == 1 {
stem = filepath.Base(realPath)
} else {
stem = filepath.Base(filepath.Dir(realPath))
}
}
if stem == "" || stem == "." || stem == "/" {
return "download"
}
return stem
}
// serveArchiveWithServeContent sends a built archive using ServeContent (Range-capable).
// For share links with MaxBandwidth > 0, outbound data is throttled via newThrottledReadSeeker (same limit as single-file download).
func serveArchiveWithServeContent(w http.ResponseWriter, r *http.Request, d *requestContext, rs io.ReadSeeker, fi os.FileInfo, originalFileName string) (int, error) {
setContentDisposition(w, r, originalFileName)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "private")
w.Header().Set("X-Content-Type-Options", "nosniff")
reader := rs
if d.share != nil && d.share.MaxBandwidth > 0 {
limit := rate.Limit(d.share.MaxBandwidth * 1024)
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(rs, limit, burst, r.Context())
}
http.ServeContent(w, r, originalFileName, fi.ModTime(), reader)
return 0, nil
}
// archiveCreateHandler creates an archive on the server at the given destination.
// POST /resources/archive — server-side only; does not return archive data.
//
@@ -562,14 +710,20 @@ func createTarGzWithLevel(d *requestContext, source string, w io.Writer, level i
return nil
}
// BuildAndStreamArchive resolves paths, creates a zip or tar.gz archive, and streams it to w.
// It respects access rules. Used only by the raw handler for multi-file/directory download.
// BuildAndStreamArchive builds a zip or tar.gz for multi-file/directory download.
// Plain GET without Range streams the archive straight to the response (no temp file), matching
// clients with chunked downloads disabled. HEAD or Range builds a temp file under cacheDir downloads,
// returns X-Archive-Token on the first response, and serves via ServeContent for Range/resume;
// follow-up GETs use ?archiveToken=.... Idle chunked sessions delete the spool file after
// archiveMultiRequestIdle without another request.
//
// server.maxArchiveSizeGB is enforced only for the HEAD/Range spool path.
func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
realPath, isDir, err := idx.GetRealPath(fileList[0])
realPath, _, err := idx.GetRealPath(fileList[0])
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get real path for %s: %v", fileList[0], err)
}
@@ -585,35 +739,142 @@ func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusInternalServerError, errors.New("format not implemented")
}
baseDirName := filepath.Base(filepath.Dir(realPath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
if len(fileList) == 1 && isDir {
baseDirName = filepath.Base(realPath)
}
originalFileName := baseDirName + extension
originalFileName := archiveAttachmentStem(fileList, realPath) + extension
setContentDisposition(w, r, originalFileName)
w.Header().Set("Content-Type", "application/octet-stream")
var writer io.Writer = w
if d.share != nil && d.share.MaxBandwidth > 0 {
limit := rate.Limit(d.share.MaxBandwidth * 1024)
burst := d.share.MaxBandwidth * 1024
writer = newThrottledWriter(w, limit, burst, r.Context())
token := r.URL.Query().Get("archiveToken")
if token != "" {
tmpPath, ok := archiveSpoolPathCache.Get(token)
if !ok {
return http.StatusGone, fmt.Errorf("invalid or expired archiveToken")
}
archiveSpoolPathCache.SetWithExp(token, tmpPath, archiveMultiRequestIdle)
rescheduleArchiveSpoolIdleCleanup(token, tmpPath)
if _, err = os.Stat(tmpPath); err != nil {
archiveSpoolPathCache.Delete(token)
return http.StatusGone, fmt.Errorf("archive no longer available")
}
var fd *os.File
fd, err = os.Open(tmpPath)
if err != nil {
return http.StatusGone, err
}
var fi os.FileInfo
fi, err = fd.Stat()
if err != nil {
_ = fd.Close()
return http.StatusInternalServerError, err
}
code, srvErr := serveArchiveWithServeContent(w, r, d, fd, fi, originalFileName)
if closeErr := fd.Close(); closeErr != nil && srvErr == nil {
srvErr = closeErr
}
if srvErr == nil && archiveGetDeliversThroughEOF(r, fi.Size()) {
removeSpooledArchiveNow(token, tmpPath)
}
return code, srvErr
}
// HEAD or Range: same condition as chunked archive probing / retained spool (X-Archive-Token).
needMultiRequestSession := r.Method == http.MethodHead || r.Header.Get("Range") != ""
if needMultiRequestSession && config.Server.MaxArchiveSizeGB > 0 {
var estimatedSize int64
estimatedSize, err = computeArchiveSize(source, fileList, d)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to compute archive size: %v", err)
}
maxSizeBytes := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
if estimatedSize > maxSizeBytes {
return http.StatusRequestEntityTooLarge, fmt.Errorf("archive size would exceed the maximum allowed size (maxArchiveSize: %d GB)", config.Server.MaxArchiveSizeGB)
}
}
// Direct streaming: no temp file (typical browser download when chunked downloads are disabled).
if !needMultiRequestSession {
if r.Method != http.MethodGet {
return http.StatusMethodNotAllowed, fmt.Errorf("method not allowed")
}
setContentDisposition(w, r, originalFileName)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "private")
w.Header().Set("X-Content-Type-Options", "nosniff")
writer := io.Writer(w)
if d.share != nil && d.share.MaxBandwidth > 0 {
limit := rate.Limit(d.share.MaxBandwidth * 1024)
burst := d.share.MaxBandwidth * 1024
writer = newThrottledWriter(w, limit, burst, r.Context())
}
if extension == ".zip" {
err = createZip(d, source, writer, fileList...)
} else {
err = createTarGz(d, source, writer, fileList...)
}
if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
dlDir := settings.DownloadCacheDir()
tmpF, err := os.CreateTemp(dlDir, "dl-archive-*"+extension)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("create temp archive: %w", err)
}
tmpPath := tmpF.Name()
if extension == ".zip" {
err = createZip(d, source, writer, fileList...)
err = createZip(d, source, tmpF, fileList...)
} else {
err = createTarGz(d, source, writer, fileList...)
err = createTarGz(d, source, tmpF, fileList...)
}
if err != nil {
_ = tmpF.Close()
_ = os.Remove(tmpPath)
return http.StatusInternalServerError, err
}
return 0, nil
if _, err = tmpF.Seek(0, 0); err != nil {
_ = tmpF.Close()
_ = os.Remove(tmpPath)
return http.StatusInternalServerError, fmt.Errorf("seek temp archive: %w", err)
}
fi, err := tmpF.Stat()
if err != nil {
_ = tmpF.Close()
_ = os.Remove(tmpPath)
return http.StatusInternalServerError, err
}
sizeInMB := fi.Size() / 1024 / 1024
if sizeInMB > 500 {
logger.Debugf("User %v is downloading large (%d MB) archive: %v", d.user.Username, sizeInMB, originalFileName)
}
// needMultiRequestSession: HEAD or Range — always spool and use ServeContent (+ token for follow-ups).
if err = tmpF.Close(); err != nil {
_ = os.Remove(tmpPath)
return http.StatusInternalServerError, err
}
newTok, err := randomArchiveToken()
if err != nil {
_ = os.Remove(tmpPath)
return http.StatusInternalServerError, err
}
archiveSpoolPathCache.SetWithExp(newTok, tmpPath, archiveMultiRequestIdle)
rescheduleArchiveSpoolIdleCleanup(newTok, tmpPath)
w.Header().Set("X-Archive-Token", newTok)
fd, err := os.Open(tmpPath)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
fi2, err := fd.Stat()
if err != nil {
return http.StatusInternalServerError, err
}
return serveArchiveWithServeContent(w, r, d, fd, fi2, originalFileName)
}
// archiveCreateRequest is the body for POST /resources/archive (server-side create).
+14 -17
View File
@@ -18,19 +18,14 @@ import (
)
// throttledReadSeeker is a wrapper around an io.ReadSeeker that throttles the reading speed.
// Used for single-file downloads and for share archive downloads (see serveArchiveWithServeContent);
// archives are built to a temp file first, then bytes to the client are limited on read.
type throttledReadSeeker struct {
rs io.ReadSeeker
limiter *rate.Limiter
ctx context.Context
}
// throttledWriter is a wrapper around an io.Writer that throttles the writing speed.
type throttledWriter struct {
w io.Writer
limiter *rate.Limiter
ctx context.Context
}
// newThrottledReadSeeker creates a new throttledReadSeeker.
func newThrottledReadSeeker(rs io.ReadSeeker, limit rate.Limit, burst int, ctx context.Context) *throttledReadSeeker {
return &throttledReadSeeker{
@@ -58,7 +53,13 @@ func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.rs.Seek(offset, whence)
}
// newThrottledWriter creates a new throttledWriter.
// throttledWriter wraps an io.Writer and rate-limits outbound bytes (streaming archives to the client).
type throttledWriter struct {
w io.Writer
limiter *rate.Limiter
ctx context.Context
}
func newThrottledWriter(w io.Writer, limit rate.Limit, burst int, ctx context.Context) *throttledWriter {
return &throttledWriter{
w: w,
@@ -67,13 +68,11 @@ func newThrottledWriter(w io.Writer, limit rate.Limit, burst int, ctx context.Co
}
}
func (w *throttledWriter) Write(p []byte) (n int, err error) {
n, err = w.w.Write(p)
func (tw *throttledWriter) Write(p []byte) (n int, err error) {
n, err = tw.w.Write(p)
if n > 0 {
if waitErr := w.limiter.WaitN(w.ctx, n); waitErr != nil {
if err != nil {
// The original error (like io.EOF) is potentially more important
// than the context error.
if waitErr := tw.limiter.WaitN(tw.ctx, n); waitErr != nil {
if err == nil {
err = waitErr
}
}
@@ -276,12 +275,10 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
fmt.Sprintf("OnlyOffice Server downloading file: %s", firstFilePath))
}
// Set headers
// ServeContent sets Content-Length (full resource or range); avoid pre-setting so partial/range responses stay consistent.
setContentDisposition(w, r, fileName)
w.Header().Set("Cache-Control", "private")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
// serve content allows for range requests.
// video scrubbing, etc.
// Note: http.ServeContent will respect our already-set Content-Disposition header
var reader io.ReadSeeker = fd
+25 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"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/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
@@ -98,10 +99,33 @@ func prepForFrontend(u *users.User) {
u.TOTPSecret = ""
u.TOTPNonce = ""
u.Scopes = u.GetFrontendScopes()
u.SidebarLinks = u.GetFrontendSidebarLinks()
u.SidebarLinks = GetFrontendSidebarLinks(u.SidebarLinks)
u.Locale = normalizeLocale(u.Locale)
}
// GetFrontendSidebarLinks converts the user's sidebar links from backend-style to frontend-style
// Converts source paths to source names for the frontend
func GetFrontendSidebarLinks(links []users.SidebarLink) []users.SidebarLink {
newLinks := []users.SidebarLink{}
for _, link := range links {
// For source links, validate that the source still exists
if strings.HasPrefix(link.Category, "source") {
source, ok := settings.Config.Server.SourceMap[link.SourceName]
if !ok {
continue
}
if source.Config.ResolvedRules.IndexingDisabled && link.Category != "source-minimal" {
link.Category = "source-alt"
}
link.SourceName = source.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
}
// normalizeLocale converts various locale formats (xx_xx, xx-xx, xxxx) to camelCase format (xxXX)
// Frontend expects camelCase for compound locales (zhCN, ptBR, etc.) as shown in select option values
func normalizeLocale(locale string) string {
+1 -2
View File
@@ -14,8 +14,7 @@ func setupConditionalTestIndex(conditionals settings.ConditionalFilter) *Index {
Name: "test",
Path: "/test/path",
Config: settings.SourceConfig{
DisableIndexing: false,
Conditionals: conditionals,
Conditionals: conditionals,
},
}
+3 -5
View File
@@ -26,11 +26,9 @@ func setupTestIndexForDeletion(t *testing.T) *Index {
// Create index with mock data
idx := &Index{
Source: settings.Source{
Name: "test_delete",
Path: "/mock/path",
Config: settings.SourceConfig{
DisableIndexing: false,
},
Name: "test_delete",
Path: "/mock/path",
Config: settings.SourceConfig{},
},
db: indexDB,
mock: true, // Enable mock mode
+7 -4
View File
@@ -401,7 +401,7 @@ func Initialize(source *settings.Source, mock bool, isNewDb bool) {
}
indexes[newIndex.Name] = &newIndex
indexesMutex.Unlock()
if !newIndex.Config.DisableIndexing {
if !newIndex.Config.ResolvedRules.IndexingDisabled {
logger.Infof("initializing index: [%v]", newIndex.Name)
// Start multi-scanner system (each scanner will do its own initial scan)
// Pass isNewDb flag so setupMultiScanner knows whether to load persisted complexity
@@ -834,7 +834,7 @@ func (idx *Index) processFileItem(file os.FileInfo, indexPath string, opts Optio
bubblesUpHasPreview := false
if !opts.SkipExtendedAttrs {
usedCachedPreview := false
if !idx.Config.DisableIndexing && opts.Recursive {
if !idx.Config.ResolvedRules.IndexingDisabled && opts.Recursive {
simpleType := strings.Split(itemInfo.Type, "/")[0]
if simpleType == "audio" {
previousInfo, exists := idx.GetReducedMetadata(fullCombined, false)
@@ -1024,6 +1024,9 @@ func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
// Use recursive=true for copy/move operations to capture entire tree
// Use recursive=false for simple metadata updates (like after file edits)
func (idx *Index) RefreshDirectory(indexPath string, recursive bool) error {
if idx.Config.ResolvedRules.IndexingDisabled {
return nil
}
if !strings.HasSuffix(indexPath, "/") {
indexPath = indexPath + "/"
}
@@ -1346,7 +1349,7 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHid
// IsViewableWithParentCheck checks if a path is viewable by walking up the directory tree
// Used for API requests where we need to check inherited rules from parent folders
func (idx *Index) IsViewableWithParentCheck(isDir bool, indexPath string, isSymlink bool, isHidden bool) bool {
if idx.Config.DisableIndexing || idx.Config.ResolvedRules.NoRules {
if idx.Config.ResolvedRules.IndexingDisabled || idx.Config.ResolvedRules.NoRules {
return true
}
@@ -1369,7 +1372,7 @@ func (idx *Index) IsViewableWithParentCheck(isDir bool, indexPath string, isSyml
// ShouldSkip checks if a path should be skipped from indexing
// isRoutineScan: true for scanner (no parent checks), false for API (check parents)
func (idx *Index) ShouldSkip(isDir bool, adjustedPath string, isHidden bool, isSymlink bool, isRoutineScan bool) bool {
if idx.Config.DisableIndexing {
if idx.Config.ResolvedRules.IndexingDisabled {
return true
}
rules := idx.Config.ResolvedRules
+1 -2
View File
@@ -29,8 +29,7 @@ func setupTestIndex(t *testing.T) (*Index, string, func()) {
Name: "test",
Path: "/mock/path",
Config: settings.SourceConfig{
DisableIndexing: false,
UseLogicalSize: true,
UseLogicalSize: true,
},
},
mock: true, // Enable mock mode
+6 -8
View File
@@ -67,15 +67,13 @@ func TestMultiScannerMutex(t *testing.T) {
// Create a mock index with multi-scanner support
idx := &Index{
Source: settings.Source{
Name: "test-multiscanner",
Path: "/tmp/test",
Config: settings.SourceConfig{
DisableIndexing: false,
},
Name: "test-multiscanner",
Path: "/tmp/test",
Config: settings.SourceConfig{},
},
mock: true,
db: indexDB,
scanners: make(map[string]*Scanner),
mock: true,
db: indexDB,
scanners: make(map[string]*Scanner),
}
// Create multiple scanners
-4
View File
@@ -5790,10 +5790,6 @@ const docTemplate = `{
"description": "deny access unless an \"allow\" access rule was specifically created.",
"type": "boolean"
},
"disableIndexing": {
"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"
-4
View File
@@ -5779,10 +5779,6 @@
"description": "deny access unless an \"allow\" access rule was specifically created.",
"type": "boolean"
},
"disableIndexing": {
"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"
-4
View File
@@ -1189,10 +1189,6 @@ definitions:
denyByDefault:
description: deny access unless an "allow" access rule was specifically created.
type: boolean
disableIndexing:
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
+360 -140
View File
@@ -147,9 +147,14 @@ 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 sizeThreshold = downloadChunkSizeMb * 1024 * 1024
const isMultiItemArchive =
files.length > 1 || (files.length === 1 && files[0].isDir)
const useChunkedArchive =
downloadChunkSizeMb > 0 && isMultiItemArchive
const useChunkedDownload =
downloadChunkSizeMb > 0 &&
@@ -159,24 +164,19 @@ export async function download(format, files, shareHash = "") {
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'
}
// 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 => file.path)
filePaths = files.map((file) => 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')
@@ -191,29 +191,27 @@ export async function download(format, files, shareHash = "") {
}
const params = {
file: filePaths, // Array of file paths - getApiPath will create repeated parameters
file: filePaths,
algo: format,
...(shareHash && { hash: shareHash }),
...(!shareHash && source && { source: source }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
sessionId: state.sessionId,
}
const apiPath = getApiPath("resources/download", params, false, shareHash !== "")
const url = window.origin + apiPath
// 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
if (useChunkedArchive) {
return await downloadChunkedArchive(url, format, files, filePaths, source, shareHash)
}
const link = document.createElement('a')
link.href = url
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// Clean up after a short delay
setTimeout(() => {
document.body.removeChild(link)
}, 100)
@@ -232,6 +230,319 @@ async function readDownloadErrorBody(response) {
return ''
}
/** Parse total size from Content-Range: bytes 0-0/12345 */
function totalFromContentRange(headerVal) {
if (!headerVal || typeof headerVal !== 'string') return 0
const idx = headerVal.lastIndexOf('/')
if (idx === -1) return 0
const n = parseInt(headerVal.slice(idx + 1), 10)
return Number.isFinite(n) ? n : 0
}
function appendArchiveTokenToUrl(url, token) {
if (!token) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}archiveToken=${encodeURIComponent(token)}`
}
/** @returns {Promise<{ size: number, token: string | null }>} */
async function resolveDownloadContentLength(url) {
let token = null
let res = await fetch(url, { method: 'HEAD', credentials: 'same-origin' })
if (res.ok) {
token = res.headers.get('X-Archive-Token')
const cl = res.headers.get('Content-Length')
if (cl) {
const n = parseInt(cl, 10)
if (Number.isFinite(n) && n > 0) {
return { size: n, token }
}
}
}
res = await fetch(url, {
method: 'GET',
headers: { Range: 'bytes=0-0' },
credentials: 'same-origin',
})
try {
token = res.headers.get('X-Archive-Token') || token
if (res.status === 206) {
const total = totalFromContentRange(res.headers.get('Content-Range'))
if (total > 0) {
return { size: total, token }
}
}
if (res.ok) {
const cl = res.headers.get('Content-Length')
if (cl) {
const n = parseInt(cl, 10)
if (Number.isFinite(n) && n > 0) {
return { size: n, token }
}
}
}
} finally {
if (res.body && typeof res.body.cancel === 'function') {
try {
await res.body.cancel()
} catch {
// ignore
}
}
}
return { size: 0, token: null }
}
function archiveDisplayFileName(format, files) {
const ext = format === 'zip' ? '.zip' : '.tar.gz'
const first = files[0]
if (files.length === 1 && first.isDir) {
const base =
first.name ||
(first.path && first.path.split('/').filter(Boolean).pop()) ||
'download'
return base + ext
}
if (first && first.path) {
const parts = first.path.split('/').filter(Boolean)
if (parts.length >= 2) {
return parts[parts.length - 2] + ext
}
if (parts.length === 1) {
return parts[0] + ext
}
}
return 'download' + ext
}
/**
* Range-based fetch; updates downloadManager progress for downloadId.
* @returns {Promise<Blob|null>}
*/
async function chunkedRangeDownloadToBlob(
baseUrl,
fileSize,
chunkSize,
downloadId,
abortController
) {
const chunks = []
let offset = 0
let loaded = 0
if (!Number.isFinite(fileSize) || fileSize <= 0) {
throw new Error('Invalid file size for chunked download')
}
/** May be corrected from Content-Range when the listing size disagrees with the file on disk. */
let totalSize = fileSize
while (offset < totalSize) {
const download = downloadManager.findById(downloadId)
if (download && download.status === 'cancelled') {
return null
}
const end = Math.min(offset + chunkSize - 1, totalSize - 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) {
const body = (await readDownloadErrorBody(response)).trim()
const fromHttp =
body ||
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(' ').trim()
throw new Error(fromHttp || 'Request failed')
}
if (!response.body) {
throw new Error(
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(' ').trim() ||
'No response body'
)
}
if (response.status === 206) {
const fromCr = totalFromContentRange(response.headers.get('Content-Range'))
if (fromCr > 0) {
totalSize = fromCr
const dl = downloadManager.findById(downloadId)
if (dl) {
dl.size = fromCr
}
}
} else if (response.status === 200) {
const clFull = response.headers.get('Content-Length')
if (clFull) {
const n = parseInt(clFull, 10)
if (Number.isFinite(n) && n > 0) {
totalSize = n
const dl = downloadManager.findById(downloadId)
if (dl) {
dl.size = n
}
}
}
}
const cl = response.headers.get('Content-Length')
let expectedChunkSize = end - offset + 1
if (cl) {
const n = parseInt(cl, 10)
if (Number.isFinite(n) && n > 0) {
expectedChunkSize = n
}
}
const reader = response.body.getReader()
const chunkParts = []
let chunkLoaded = 0
let lastProgressUpdate = 0
const progressUpdateInterval = Math.max(50000, expectedChunkSize / 50)
try {
let reading = true
while (reading) {
const { done, value } = await reader.read()
if (done) {
reading = false
break
}
chunkParts.push(value)
chunkLoaded += value.length
const chunkProgress = Math.min(chunkLoaded, expectedChunkSize)
const totalLoaded = offset + chunkProgress
if (
chunkLoaded - lastProgressUpdate >= progressUpdateInterval ||
chunkLoaded >= expectedChunkSize
) {
downloadManager.updateProgress(downloadId, totalLoaded, totalSize)
lastProgressUpdate = chunkLoaded
}
}
} catch (readError) {
const download = downloadManager.findById(downloadId)
if (readError.name === 'AbortError' || (download && download.status === 'cancelled')) {
downloadManager.setStatus(downloadId, 'cancelled')
return null
}
throw readError
}
if (chunkLoaded < expectedChunkSize) {
throw new Error('Connection closed before the full chunk was received')
}
const chunk = new Uint8Array(chunkLoaded)
let position = 0
for (const part of chunkParts) {
chunk.set(part, position)
position += part.length
}
const chunkToUse =
chunk.byteLength > expectedChunkSize
? chunk.slice(0, expectedChunkSize).buffer
: chunk.buffer
chunks.push(chunkToUse)
loaded += expectedChunkSize
downloadManager.updateProgress(downloadId, loaded, totalSize)
offset += expectedChunkSize
}
return new Blob(chunks, { type: 'application/octet-stream' })
}
async function downloadChunkedArchive(url, format, files, filePaths, source, shareHash) {
const downloadChunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
if (downloadChunkSizeMb === 0) {
throw new Error('Chunked download is disabled (chunk size is 0)')
}
const chunkSize = downloadChunkSizeMb * 1024 * 1024
const fileName = archiveDisplayFileName(format, files)
const { size: fileSize, token } = await resolveDownloadContentLength(url)
if (!fileSize || fileSize <= 0) {
throw new Error('Could not determine archive size for chunked download')
}
const baseUrl = appendArchiveTokenToUrl(url, token)
const stub = {
name: fileName,
path: filePaths[0],
source: source || '',
size: fileSize,
isDir: false,
}
const downloadId = downloadManager.add(stub, shareHash)
const dl = downloadManager.findById(downloadId)
if (dl) {
dl.archiveFormat = format
dl.archiveFiles = files
}
downloadManager.setStatus(downloadId, 'downloading')
const hasDownloadPrompt = state.prompts && state.prompts.some((h) => h.name === 'download')
if (!hasDownloadPrompt) {
mutations.showPrompt({ name: 'download' })
}
const abortController = new AbortController()
const dlm = downloadManager.findById(downloadId)
if (dlm) {
dlm.abortController = abortController
}
try {
const blob = await chunkedRangeDownloadToBlob(
baseUrl,
fileSize,
chunkSize,
downloadId,
abortController
)
if (!blob) {
return
}
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = fileName
link.style.display = 'none'
document.body.appendChild(link)
link.click()
downloadManager.setStatus(downloadId, 'completed')
downloadManager.updateProgress(downloadId, fileSize, fileSize)
setTimeout(() => {
document.body.removeChild(link)
URL.revokeObjectURL(blobUrl)
}, 100)
} catch (error) {
const download = downloadManager.findById(downloadId)
if (error.name === 'AbortError' || (download && download.status === 'cancelled')) {
downloadManager.setStatus(downloadId, 'cancelled')
return
}
const message =
error instanceof Error ? error.message || 'Unknown error' : String(error)
downloadManager.setError(downloadId, message)
throw error
}
}
async function downloadChunked(file, shareHash = "") {
const chunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
@@ -239,23 +550,10 @@ async function downloadChunked(file, shareHash = "") {
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.prompts && state.prompts.some(h => h.name === 'download');
if (!hasDownloadPrompt) {
mutations.showPrompt({ name: 'download' })
}
const params = {
file: file.path,
...(shareHash && { hash: shareHash }),
@@ -267,123 +565,45 @@ async function downloadChunked(file, shareHash = "") {
const apiPath = getApiPath("resources/download", params, false, shareHash !== "")
const baseUrl = window.origin + apiPath
let fileSize = Number(file.size)
const { size: serverLen } = await resolveDownloadContentLength(baseUrl)
if (Number.isFinite(serverLen) && serverLen > 0) {
fileSize = serverLen
}
if (!Number.isFinite(fileSize) || fileSize <= 0) {
throw new Error('Could not determine file size for chunked download')
}
const queuedFile = { ...file, size: fileSize }
const downloadId = downloadManager.add(queuedFile, 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.prompts && state.prompts.some(h => h.name === 'download');
if (!hasDownloadPrompt) {
mutations.showPrompt({ name: 'download' })
}
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) {
const body = (await readDownloadErrorBody(response)).trim()
const fromHttp =
body ||
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(" ").trim()
throw new Error(fromHttp || "Request failed")
}
if (!response.body) {
throw new Error(
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(" ").trim() ||
"No response body"
)
}
// Track progress within the chunk using ReadableStream
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
}
if (chunkLoaded < expectedChunkSize) {
throw new Error("Connection closed before the full chunk was received")
}
// Combine chunk parts into single ArrayBuffer
const chunk = new Uint8Array(chunkLoaded);
let position = 0;
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
const blob = await chunkedRangeDownloadToBlob(
baseUrl,
fileSize,
chunkSize,
downloadId,
abortController
)
if (!blob) {
return
}
// 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
@@ -391,11 +611,11 @@ async function downloadChunked(file, shareHash = "") {
document.body.appendChild(link)
link.click()
// Mark as completed
downloadManager.setStatus(downloadId, "completed")
downloadManager.updateProgress(downloadId, fileSize, fileSize)
const dlDone = downloadManager.findById(downloadId)
const finalSize = dlDone?.size ?? fileSize
downloadManager.updateProgress(downloadId, finalSize, finalSize)
// Clean up
setTimeout(() => {
document.body.removeChild(link)
URL.revokeObjectURL(blobUrl)
+16 -1
View File
@@ -106,6 +106,9 @@ export default {
);
},
},
beforeUnmount() {
downloadManager.reset();
},
methods: {
formatDownloadError(download) {
if (download.status !== "error") {
@@ -131,7 +134,19 @@ export default {
// Re-trigger download by calling the download function again
downloadManager.remove(id);
try {
await resourcesApi.download(null, [download.file], download.shareHash);
if (
download.archiveFormat != null &&
Array.isArray(download.archiveFiles) &&
download.archiveFiles.length > 0
) {
await resourcesApi.download(
download.archiveFormat,
download.archiveFiles,
download.shareHash || ""
);
} else {
await resourcesApi.download(null, [download.file], download.shareHash);
}
} catch (err) {
console.error('Retry download failed:', err);
}
+4
View File
@@ -401,6 +401,7 @@ export const mutations = {
state.user.fileLoading = {
maxConcurrentUpload: 3,
uploadChunkSizeMb: 5,
downloadChunkSizeMb: 0,
clearAll: false
};
} else {
@@ -414,6 +415,9 @@ export const mutations = {
if (state.user.fileLoading.clearAll === undefined) {
state.user.fileLoading.clearAll = false;
}
if (state.user.fileLoading.downloadChunkSizeMb === undefined) {
state.user.fileLoading.downloadChunkSizeMb = 0;
}
}
// Load display preferences for the current user
+30 -14
View File
@@ -12,24 +12,34 @@ export default function downloadFiles(items) {
items = items.map(i => state.req.items[i]);
}
// Check if chunked download will be used (single file only)
// Chunked single-file (large) vs chunked multi-item archive (folder / multi-select)
const downloadChunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
const sizeThreshold = downloadChunkSizeMb * 1024 * 1024;
const willUseChunkedDownload =
downloadChunkSizeMb > 0 &&
items.length === 1 &&
!items[0].isDir &&
items[0].size &&
const willUseChunkedDownload =
downloadChunkSizeMb > 0 &&
items.length === 1 &&
!items[0].isDir &&
items[0].size &&
items[0].size >= sizeThreshold
const isMultiItemArchive =
items.length > 1 || (items.length === 1 && items[0].isDir)
const willUseChunkedArchive =
downloadChunkSizeMb > 0 && isMultiItemArchive
const showChunkedProgressFirst =
willUseChunkedDownload || willUseChunkedArchive
if (getters.isShare()) {
// Perform download without opening a new window
if (getters.isSingleFileSelected()) {
// Show download prompt for chunked downloads, otherwise start directly
if (willUseChunkedDownload) {
if (showChunkedProgressFirst) {
mutations.showPrompt({ name: "download" });
startDownload(null, items, state.shareInfo.hash, { silentChunkedError: true });
startDownload(null, items, state.shareInfo.hash, {
silentChunkedError: true,
});
} else {
startDownload(null, items, state.shareInfo.hash);
}
@@ -39,7 +49,9 @@ export default function downloadFiles(items) {
name: "download",
confirm: (format) => {
mutations.closeTopPrompt();
startDownload(format, items, state.shareInfo.hash);
startDownload(format, items, state.shareInfo.hash, {
silentChunkedError: willUseChunkedArchive,
});
},
});
}
@@ -47,8 +59,7 @@ export default function downloadFiles(items) {
}
if (getters.isSingleFileSelected()) {
// Show download prompt for chunked downloads, otherwise start directly
if (willUseChunkedDownload) {
if (showChunkedProgressFirst) {
mutations.showPrompt({ name: "download" });
startDownload(null, items, "", { silentChunkedError: true });
} else {
@@ -60,13 +71,15 @@ export default function downloadFiles(items) {
name: "download",
confirm: (format) => {
mutations.closeTopPrompt();
startDownload(format, items);
startDownload(format, items, "", {
silentChunkedError: willUseChunkedArchive,
});
},
});
}
}
async function startDownload(config, files, hash = "") {
async function startDownload(config, files, hash = "", options = {}) {
try {
notify.showSuccessToast("Downloading...");
await resourcesApi.download(config, files, hash);
@@ -74,6 +87,9 @@ async function startDownload(config, files, hash = "") {
if (e?.name === "AbortError" || e?.message?.includes("aborted") || e?.message?.includes("cancelled")) {
return;
}
if (options.silentChunkedError) {
return;
}
notify.showError(`Error downloading: ${e.message || e}`);
}
}
+20
View File
@@ -82,6 +82,26 @@ class DownloadManager {
}
}
/** Abort in-flight downloads, clear the queue, reset ids (fresh Download prompt instance). */
reset() {
if (!this.queue) {
this.queue = reactive([]);
this.nextId = 0;
return;
}
for (const item of this.queue) {
if (item.abortController) {
try {
item.abortController.abort();
} catch {
// ignore
}
}
}
this.queue.splice(0, this.queue.length);
this.nextId = 0;
}
hasActive() {
if (!this.queue) return false;
return this.queue.some((item) => item.status === "downloading" || item.status === "pending");
+3 -3
View File
@@ -17,7 +17,7 @@ async function globalSetup() {
expect(cookies.find((c) => c.name === "filebrowser_quantum_jwt")?.value).toBeDefined();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.waitForURL("**/files/playwright%20+%20files", { timeout: 1000 });
await page.waitForURL("**/files/playwright%20+%20files/", { timeout: 1000 });
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
@@ -39,7 +39,7 @@ async function globalSetup() {
localStorage.setItem('shareHash', hash);
}, shareHash);
await page.goto("http://127.0.0.1/files/playwright%20%2B%20files", { timeout: 1000 });
await page.goto("http://127.0.0.1/files/playwright%20%2B%20files/", { timeout: 1000 });
// Create a share of file
await page.locator('a[aria-label="1file1.txt"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="1file1.txt"]').click({ button: "right" });
@@ -59,7 +59,7 @@ async function globalSetup() {
}, shareHashFile);
// Create a share of root folder "/"
await page.goto("http://127.0.0.1/files/playwright%20%2B%20files", { timeout: 1000 });
await page.goto("http://127.0.0.1/files/playwright%20%2B%20files/", { timeout: 1000 });
await page.locator('a[aria-label="share"]').waitFor({ state: 'visible' });
await openContextMenuHelper(page);
await page.locator('button[aria-label="Share"]').click();
+1 -1
View File
@@ -7,7 +7,7 @@ async function globalSetup() {
const page: Page = await context.newPage();
// The final URL we expect to land on after the login dance.
const finalUrl = "http://127.0.0.1/files/playwright-files";
const finalUrl = "http://127.0.0.1/files/playwright-files/";
const oidcLoginUrl = `http://127.0.0.1/api/auth/oidc/login?redirect=${encodeURIComponent("/files")}`;
// Go directly to the OIDC login URL. This will start the redirect chain.