mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
2
Commits
main
...
v1.3.10-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c49022ea3e | ||
|
|
64e6910984 |
No files matched your search
@@ -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
|
||||
@@ -11,8 +11,10 @@ server:
|
||||
- path: "."
|
||||
name: "docker"
|
||||
config:
|
||||
disableIndexing: true
|
||||
defaultEnabled: true
|
||||
rules:
|
||||
- folderPath: "/"
|
||||
viewable: true
|
||||
- path: "/tests/playwright-files"
|
||||
name: "access"
|
||||
config:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -14,8 +14,7 @@ func setupConditionalTestIndex(conditionals settings.ConditionalFilter) *Index {
|
||||
Name: "test",
|
||||
Path: "/test/path",
|
||||
Config: settings.SourceConfig{
|
||||
DisableIndexing: false,
|
||||
Conditionals: conditionals,
|
||||
Conditionals: conditionals,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user