diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6d5e61a5..83fb21fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,36 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
+## [2.63.18](https://github.com/filebrowser/filebrowser/compare/v2.63.17...v2.63.18) (2026-07-04)
+
+
+### Bug Fixes
+
+* avoid recursive conflict checks for copy and move ([#6009](https://github.com/filebrowser/filebrowser/issues/6009)) ([dfc2e88](https://github.com/filebrowser/filebrowser/commit/dfc2e887e1a19d54984a0d7e39a2a63caf73ef19))
+* deduplicate PT language ([4470288](https://github.com/filebrowser/filebrowser/commit/4470288ba1828a14453a99348fd22c62aa0b9460))
+* **preview:** keep the EPUB table-of-contents button clear of the header ([#6010](https://github.com/filebrowser/filebrowser/issues/6010)) ([aac2516](https://github.com/filebrowser/filebrowser/commit/aac25166378422135e624e305c410c54a39374fb))
+
+## [2.63.17](https://github.com/filebrowser/filebrowser/compare/v2.63.16...v2.63.17) (2026-06-27)
+
+
+### Bug Fixes
+
+* **auth:** reject signup when normalized home dir collides (GHSA-7rc3-g7h6-22m7) ([883a36f](https://github.com/filebrowser/filebrowser/commit/883a36f02fcb69566a8628cb47f18fdc73348387))
+* match admin share paths by owner scope ([#5992](https://github.com/filebrowser/filebrowser/issues/5992)) ([43a404c](https://github.com/filebrowser/filebrowser/commit/43a404ca69bf25553bfbbb2b446f0f53077c6302))
+* normalize recursive listing paths to forward slashes ([#6003](https://github.com/filebrowser/filebrowser/issues/6003)) ([2472fbc](https://github.com/filebrowser/filebrowser/commit/2472fbcd30502606feb11fbc8b8dc4f3803e6641))
+* preserve SRT subtitle line breaks ([#6002](https://github.com/filebrowser/filebrowser/issues/6002)) ([d9cf2f0](https://github.com/filebrowser/filebrowser/commit/d9cf2f0100d2c4892cad8e339eacca96df1aa5b6))
+* **raw:** neutralize backslashes in archive entry names (GHSA-83xp-526h-j3ww) ([8503ba6](https://github.com/filebrowser/filebrowser/commit/8503ba61ff51d48a7313896483d130eb6a5abfe0))
+* **share:** delete exact directory share on trailing-slash delete (GHSA-pp88-jhwj-5qh5) ([f30fca6](https://github.com/filebrowser/filebrowser/commit/f30fca636c1af9ef401e9a82ff60391cb3db97e1))
+* **share:** stop exposing password hash and bypass token in share API (GHSA-833g-cqhp-h72j) ([ec13054](https://github.com/filebrowser/filebrowser/commit/ec130546713c44cd24556907552ac554c7f809c9))
+
+## [2.63.16](https://github.com/filebrowser/filebrowser/compare/v2.63.15...v2.63.16) (2026-06-23)
+
+
+### Bug Fixes
+
+* dangling symlink, write, delete scope bugs ([64511ce](https://github.com/filebrowser/filebrowser/commit/64511ce45e3be379e965f7f4fb0929a068d5bb81))
+* restore symlink behavior as opt-in followExternalSymlinks ([a106392](https://github.com/filebrowser/filebrowser/commit/a1063925e15ef27f9d5dc26aae371bbf52af608c))
+
## [2.63.15](https://github.com/filebrowser/filebrowser/compare/v2.63.14...v2.63.15) (2026-06-13)
diff --git a/auth/hook.go b/auth/hook.go
index 60c75461..a6dc25b8 100644
--- a/auth/hook.go
+++ b/auth/hook.go
@@ -68,7 +68,7 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store, stg *settings.Settings
case "block":
return nil, os.ErrPermission
case "pass":
- u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
+ u, err := a.Users.Get(a.Server.Root, a.Server.FollowExternalSymlinks, a.Cred.Username)
if err != nil || !users.CheckPwd(a.Cred.Password, u.Password) {
return nil, os.ErrPermission
}
@@ -129,7 +129,7 @@ func (a *HookAuth) GetValues(s string) {
// SaveUser updates the existing user or creates a new one when not found
func (a *HookAuth) SaveUser() (*users.User, error) {
- u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
+ u, err := a.Users.Get(a.Server.Root, a.Server.FollowExternalSymlinks, a.Cred.Username)
if err != nil && !errors.Is(err, fberrors.ErrNotExist) {
return nil, err
}
diff --git a/auth/json.go b/auth/json.go
index 2284dc7f..be8ad1dc 100644
--- a/auth/json.go
+++ b/auth/json.go
@@ -55,7 +55,7 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store, _ *settings.Settings, s
}
}
- u, err := usr.Get(srv.Root, cred.Username)
+ u, err := usr.Get(srv.Root, srv.FollowExternalSymlinks, cred.Username)
hash := dummyHash
if err == nil {
diff --git a/auth/none.go b/auth/none.go
index c9381a83..30ea7129 100644
--- a/auth/none.go
+++ b/auth/none.go
@@ -15,7 +15,7 @@ type NoAuth struct{}
// Auth uses authenticates user 1.
func (a NoAuth) Auth(_ *http.Request, usr users.Store, _ *settings.Settings, srv *settings.Server) (*users.User, error) {
- return usr.Get(srv.Root, uint(1))
+ return usr.Get(srv.Root, srv.FollowExternalSymlinks, uint(1))
}
// LoginPage tells that no auth doesn't require a login page.
diff --git a/auth/proxy.go b/auth/proxy.go
index 57eddd4a..ab6227d4 100644
--- a/auth/proxy.go
+++ b/auth/proxy.go
@@ -20,7 +20,7 @@ type ProxyAuth struct {
// Auth authenticates the user via an HTTP header.
func (a ProxyAuth) Auth(r *http.Request, usr users.Store, setting *settings.Settings, srv *settings.Server) (*users.User, error) {
username := r.Header.Get(a.Header)
- user, err := usr.Get(srv.Root, username)
+ user, err := usr.Get(srv.Root, srv.FollowExternalSymlinks, username)
if errors.Is(err, fberrors.ErrNotExist) {
return a.createUser(usr, setting, srv, username)
}
diff --git a/auth/proxy_test.go b/auth/proxy_test.go
index 86fb7102..9b9ef3a7 100644
--- a/auth/proxy_test.go
+++ b/auth/proxy_test.go
@@ -13,7 +13,7 @@ type mockUserStore struct {
users map[string]*users.User
}
-func (m *mockUserStore) Get(_ string, id interface{}) (*users.User, error) {
+func (m *mockUserStore) Get(_ string, _ bool, id interface{}) (*users.User, error) {
if v, ok := id.(string); ok {
if u, ok := m.users[v]; ok {
return u, nil
@@ -22,14 +22,15 @@ func (m *mockUserStore) Get(_ string, id interface{}) (*users.User, error) {
return nil, fberrors.ErrNotExist
}
-func (m *mockUserStore) Gets(_ string) ([]*users.User, error) { return nil, nil }
-func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil }
+func (m *mockUserStore) GetByScope(_ string) (*users.User, error) { return nil, fberrors.ErrNotExist }
+func (m *mockUserStore) Gets(_ string, _ bool) ([]*users.User, error) { return nil, nil }
+func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil }
func (m *mockUserStore) Save(user *users.User) error {
m.users[user.Username] = user
return nil
}
func (m *mockUserStore) Delete(_ interface{}) error { return nil }
-func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 }
+func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 }
func TestProxyAuthCreateUserRestrictsDefaults(t *testing.T) {
t.Parallel()
diff --git a/cmd/config.go b/cmd/config.go
index e3bb2b86..cf923eac 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -229,6 +229,7 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Fprintf(w, "\tThumbnails Enabled:\t%t\n", ser.EnableThumbnails)
fmt.Fprintf(w, "\tResize Preview:\t%t\n", ser.ResizePreview)
fmt.Fprintf(w, "\tType Detection by Header:\t%t\n", ser.TypeDetectionByHeader)
+ fmt.Fprintf(w, "\tFollow External Symlinks:\t%t\n", ser.FollowExternalSymlinks)
fmt.Fprintln(w, "\nTUS:")
fmt.Fprintf(w, "\tChunk size:\t%d\n", set.Tus.ChunkSize)
diff --git a/cmd/root.go b/cmd/root.go
index 13139a7e..1d34f866 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -13,6 +13,7 @@ import (
"os"
"os/signal"
"path/filepath"
+ "strings"
"syscall"
"time"
@@ -111,6 +112,7 @@ func addServerFlags(flags *pflag.FlagSet) {
flags.Bool("disableExec", true, "disables Command Runner feature")
flags.Bool("disableTypeDetectionByHeader", false, "disables type detection by reading file headers")
flags.Bool("disableImageResolutionCalc", false, "disables image resolution calculation by reading image files")
+ flags.Bool("followExternalSymlinks", false, "follow symlinks whose target is outside the user scope (unsafe)")
}
var rootCmd = &cobra.Command{
@@ -353,6 +355,10 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
server.EnableExec = !v.GetBool("disableExec")
}
+ if v.IsSet("followExternalSymlinks") {
+ server.FollowExternalSymlinks = v.GetBool("followExternalSymlinks")
+ }
+
if isAddrSet && isSocketSet {
return nil, errors.New("--socket flag cannot be used with --address, --port, --key nor --cert")
}
@@ -369,6 +375,25 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
log.Println("WARNING: read https://github.com/filebrowser/filebrowser/issues/5199")
}
+ if server.FollowExternalSymlinks {
+ log.Println("WARNING: Following external symlinks enabled!")
+ log.Println("WARNING: Symlinks pointing outside a user's scope will be followed,")
+ log.Println("WARNING: which can expose files outside that scope. Only enable this if")
+ log.Println("WARNING: you fully understand and trust the contents of every user scope.")
+ }
+
+ if set, err := st.Settings.Get(); err == nil && set.Signup {
+ scope := strings.TrimSpace(set.Defaults.Scope)
+ scopeIsRoot := scope == "" || scope == "." || scope == "/"
+
+ if !set.CreateUserDir && scopeIsRoot {
+ log.Println("WARNING: Signup is enabled without createUserDir and the default scope is")
+ log.Println("WARNING: the server root, so every self-registered user can read, modify and")
+ log.Println("WARNING: delete all files File Browser serves, including other users' files.")
+ log.Println("WARNING: Enable createUserDir, or set a default scope other than the root.")
+ }
+ }
+
return server, nil
}
@@ -446,19 +471,20 @@ func quickSetup(v *viper.Viper, s *storage.Storage) error {
}
ser := &settings.Server{
- BaseURL: v.GetString("baseURL"),
- Port: v.GetString("port"),
- Log: v.GetString("log"),
- TLSKey: v.GetString("key"),
- TLSCert: v.GetString("cert"),
- Address: v.GetString("address"),
- Root: v.GetString("root"),
- TokenExpirationTime: v.GetString("tokenExpirationTime"),
- EnableThumbnails: !v.GetBool("disableThumbnails"),
- ResizePreview: !v.GetBool("disablePreviewResize"),
- EnableExec: !v.GetBool("disableExec"),
- TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"),
- ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"),
+ BaseURL: v.GetString("baseURL"),
+ Port: v.GetString("port"),
+ Log: v.GetString("log"),
+ TLSKey: v.GetString("key"),
+ TLSCert: v.GetString("cert"),
+ Address: v.GetString("address"),
+ Root: v.GetString("root"),
+ TokenExpirationTime: v.GetString("tokenExpirationTime"),
+ EnableThumbnails: !v.GetBool("disableThumbnails"),
+ ResizePreview: !v.GetBool("disablePreviewResize"),
+ EnableExec: !v.GetBool("disableExec"),
+ TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"),
+ ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"),
+ FollowExternalSymlinks: v.GetBool("followExternalSymlinks"),
}
err = s.Settings.SaveServer(ser)
diff --git a/cmd/rules.go b/cmd/rules.go
index bdb1d1cf..f3e00bb6 100644
--- a/cmd/rules.go
+++ b/cmd/rules.go
@@ -36,7 +36,7 @@ func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User)
}
if id != nil {
var user *users.User
- user, err = st.Users.Get("", id)
+ user, err = st.Users.Get("", false, id)
if err != nil {
return err
}
diff --git a/cmd/users_export.go b/cmd/users_export.go
index 9bbec6d8..768c381f 100644
--- a/cmd/users_export.go
+++ b/cmd/users_export.go
@@ -15,7 +15,7 @@ var usersExportCmd = &cobra.Command{
path to the file where you want to write the users.`,
Args: jsonYamlArg,
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
- list, err := st.Users.Gets("")
+ list, err := st.Users.Gets("", false)
if err != nil {
return err
}
diff --git a/cmd/users_find.go b/cmd/users_find.go
index 09bc8d47..fc91af86 100644
--- a/cmd/users_find.go
+++ b/cmd/users_find.go
@@ -36,14 +36,14 @@ var findUsers = withStore(func(_ *cobra.Command, args []string, st *store) error
if len(args) == 1 {
username, id := parseUsernameOrID(args[0])
if username != "" {
- user, err = st.Users.Get("", username)
+ user, err = st.Users.Get("", false, username)
} else {
- user, err = st.Users.Get("", id)
+ user, err = st.Users.Get("", false, id)
}
list = []*users.User{user}
} else {
- list, err = st.Users.Gets("")
+ list, err = st.Users.Gets("", false)
}
if err != nil {
diff --git a/cmd/users_import.go b/cmd/users_import.go
index 73effca6..0db3704f 100644
--- a/cmd/users_import.go
+++ b/cmd/users_import.go
@@ -40,7 +40,7 @@ list or set it to 0.`,
}
for _, user := range list {
- err = user.Clean("")
+ err = user.Clean("", false)
if err != nil {
return err
}
@@ -52,7 +52,7 @@ list or set it to 0.`,
}
if replace {
- oldUsers, userImportErr := st.Users.Gets("")
+ oldUsers, userImportErr := st.Users.Gets("", false)
if userImportErr != nil {
return userImportErr
}
@@ -76,7 +76,7 @@ list or set it to 0.`,
}
for _, user := range list {
- onDB, err := st.Users.Get("", user.ID)
+ onDB, err := st.Users.Get("", false, user.ID)
// User exists in DB.
if err == nil {
@@ -88,7 +88,7 @@ list or set it to 0.`,
// with the new username. If there is, print an error and cancel the
// operation
if user.Username != onDB.Username {
- if conflictuous, err := st.Users.Get("", user.Username); err == nil {
+ if conflictuous, err := st.Users.Get("", false, user.Username); err == nil {
return usernameConflictError(user.Username, conflictuous.ID, user.ID)
}
}
diff --git a/cmd/users_update.go b/cmd/users_update.go
index e9a484fc..12484342 100644
--- a/cmd/users_update.go
+++ b/cmd/users_update.go
@@ -43,9 +43,9 @@ options you want to change.`,
user *users.User
)
if id != 0 {
- user, err = st.Users.Get("", id)
+ user, err = st.Users.Get("", false, id)
} else {
- user, err = st.Users.Get("", username)
+ user, err = st.Users.Get("", false, username)
}
if err != nil {
return err
diff --git a/files/file_test.go b/files/file_test.go
index b5a8b37f..45b114f6 100644
--- a/files/file_test.go
+++ b/files/file_test.go
@@ -92,6 +92,125 @@ func TestScopedFs(t *testing.T) {
t.Fatalf("expected in-scope symlink target to be accessible, got %v", err)
}
})
+
+ // Regression for the dangling-symlink write escape (GHSA-8wc8-hf36-mjh9 /
+ // GHSA-fh54-6rfh-r8f3): a symlink whose target does not exist yet must not be
+ // followed for writes. Previously within() validated the link's in-scope
+ // parent directory, so OpenFile(O_CREATE) dereferenced the link and created
+ // the file at its out-of-scope target.
+ t.Run("write through a dangling escaping symlink is rejected", func(t *testing.T) {
+ base := t.TempDir()
+ scope := filepath.Join(base, "scope")
+ outside := filepath.Join(base, "outside")
+ for _, d := range []string{scope, outside} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ outsideTarget := filepath.Join(outside, "created.txt") // does not exist yet
+ if err := os.Symlink(outsideTarget, filepath.Join(scope, "evil")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+ fs := NewScopedFs(afero.NewOsFs(), scope)
+
+ f, err := fs.OpenFile("/evil", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
+ if err == nil {
+ _ = f.Close()
+ t.Fatal("VULNERABLE: write through a dangling escaping symlink was allowed")
+ }
+ if !os.IsPermission(err) {
+ t.Fatalf("expected permission error, got %v", err)
+ }
+ if _, statErr := os.Stat(outsideTarget); statErr == nil {
+ t.Fatal("VULNERABLE: file was created outside the scope")
+ }
+ })
+
+ // A dangling *relative* symlink that lives under an escaping directory
+ // symlink must be resolved against the link's real directory, not its lexical
+ // parent. Otherwise the symlinked ancestor can shift the computed target back
+ // into scope while the real OS write lands outside it.
+ t.Run("write through a dangling relative symlink under a symlinked dir is rejected", func(t *testing.T) {
+ base := t.TempDir()
+ scope := filepath.Join(base, "scope")
+ outside := filepath.Join(base, "outside")
+ for _, d := range []string{scope, outside} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // An escaping directory symlink inside the scope: /scope/m -> /base/outside.
+ if err := os.Symlink(outside, filepath.Join(scope, "m")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+ // A relative dangling symlink inside the escaping dir whose target,
+ // resolved against the real directory (/base/outside), is /base/escaped —
+ // outside the scope.
+ if err := os.Symlink("../escaped", filepath.Join(outside, "evil")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+ fs := NewScopedFs(afero.NewOsFs(), scope)
+
+ f, err := fs.OpenFile("/m/evil", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
+ if err == nil {
+ _ = f.Close()
+ t.Fatal("VULNERABLE: write through a dangling relative symlink under a symlinked dir was allowed")
+ }
+ if !os.IsPermission(err) {
+ t.Fatalf("expected permission error, got %v", err)
+ }
+ if _, statErr := os.Stat(filepath.Join(base, "escaped")); statErr == nil {
+ t.Fatal("VULNERABLE: file was created outside the scope")
+ }
+ })
+
+ // Regression for the symlink-following delete escape (GHSA-hq4g-mpch-f9vp /
+ // GHSA-fmm7-x4gx-8jhr): Remove/RemoveAll used to skip guard(), so RemoveAll
+ // followed a symlinked ancestor escaping the scope and deleted an
+ // out-of-scope file.
+ t.Run("RemoveAll through an escaping symlink is rejected", func(t *testing.T) {
+ base := t.TempDir()
+ scope := filepath.Join(base, "scope")
+ outside := filepath.Join(base, "outside")
+ for _, d := range []string{scope, outside} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ victim := filepath.Join(outside, "victim.txt")
+ if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join(scope, "link")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+ fs := NewScopedFs(afero.NewOsFs(), scope)
+
+ if err := fs.RemoveAll("/link/victim.txt"); !os.IsPermission(err) {
+ t.Fatalf("expected RemoveAll through escaping symlink to be rejected, got %v", err)
+ }
+ if _, statErr := os.Stat(victim); statErr != nil {
+ t.Fatalf("VULNERABLE: out-of-scope victim file was deleted: %v", statErr)
+ }
+ })
+
+ // The guard added for the delete escape must not break legitimate deletes of
+ // in-scope files.
+ t.Run("RemoveAll of an in-scope file is allowed", func(t *testing.T) {
+ scope := t.TempDir()
+ target := filepath.Join(scope, "deleteme.txt")
+ if err := os.WriteFile(target, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ fs := NewScopedFs(afero.NewOsFs(), scope)
+
+ if err := fs.RemoveAll("/deleteme.txt"); err != nil {
+ t.Fatalf("expected in-scope RemoveAll to succeed, got %v", err)
+ }
+ if _, statErr := os.Stat(target); statErr == nil {
+ t.Fatal("expected in-scope file to be deleted")
+ }
+ })
}
// stat must reject a regular file reached through a symlinked ancestor that
diff --git a/files/fs_test.go b/files/fs_test.go
new file mode 100644
index 00000000..5974b20a
--- /dev/null
+++ b/files/fs_test.go
@@ -0,0 +1,121 @@
+package files
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/afero"
+)
+
+// TestNewFs verifies that NewFs picks the right implementation and that the
+// follow-external-symlinks toggle flips whether a symlink pointing outside the
+// scope is honored.
+func TestNewFs(t *testing.T) {
+ base := t.TempDir()
+ scope := filepath.Join(base, "srv")
+ outside := filepath.Join(base, "outside")
+ for _, d := range []string{scope, outside} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // A symlink lexically inside the scope whose target resolves outside it.
+ if err := os.Symlink(outside, filepath.Join(scope, "escape")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+
+ t.Run("disabled returns a ScopedFs that rejects the escaping symlink", func(t *testing.T) {
+ fs := NewFs(afero.NewOsFs(), scope, false)
+ if _, ok := fs.(*ScopedFs); !ok {
+ t.Fatalf("expected *ScopedFs, got %T", fs)
+ }
+ if _, err := fs.Stat("/escape"); !os.IsPermission(err) {
+ t.Fatalf("expected stat of escaping symlink to be rejected, got %v", err)
+ }
+ })
+
+ t.Run("enabled returns a BasePathFs that follows the escaping symlink", func(t *testing.T) {
+ fs := NewFs(afero.NewOsFs(), scope, true)
+ if _, ok := fs.(*afero.BasePathFs); !ok {
+ t.Fatalf("expected *afero.BasePathFs, got %T", fs)
+ }
+ if _, err := fs.Stat("/escape"); err != nil {
+ t.Fatalf("expected escaping symlink to be followed, got %v", err)
+ }
+ b, err := afero.ReadFile(fs, "/escape/secret.txt")
+ if err != nil {
+ t.Fatalf("expected to read through escaping symlink, got %v", err)
+ }
+ if string(b) != "secret" {
+ t.Fatalf("got %q, want %q", b, "secret")
+ }
+
+ // The link must also appear in a directory listing (the symptom in #5998).
+ entries, err := afero.ReadDir(fs, "/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var found bool
+ for _, e := range entries {
+ if e.Name() == "escape" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("expected escaping symlink to appear in the listing")
+ }
+ })
+}
+
+// TestBasePath verifies BasePath extracts the underlying *afero.BasePathFs from
+// either filesystem NewFs may return, so User.FullPath keeps working.
+func TestBasePath(t *testing.T) {
+ root := t.TempDir()
+ osFs := afero.NewOsFs()
+
+ for _, tc := range []struct {
+ name string
+ followExternal bool
+ }{
+ {"ScopedFs", false},
+ {"BasePathFs", true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ fs := NewFs(osFs, root, tc.followExternal)
+ base := BasePath(fs)
+ if base == nil {
+ t.Fatalf("expected non-nil base for %T", fs)
+ }
+ got := afero.FullBaseFsPath(base, "/x")
+ want := filepath.Join(root, "x")
+ if got != want {
+ t.Fatalf("FullBaseFsPath: got %q, want %q", got, want)
+ }
+ })
+ }
+
+ if got := BasePath(osFs); got != nil {
+ t.Fatalf("expected nil base for a plain OsFs, got %v", got)
+ }
+}
+
+// TestFileInfoRealPathUsesBasePathFsRealPath mirrors
+// TestFileInfoRealPathUsesScopedFsRealPath for the follow-external-symlinks case,
+// where the user filesystem is a bare BasePathFs.
+func TestFileInfoRealPathUsesBasePathFsRealPath(t *testing.T) {
+ root := t.TempDir()
+ file := &FileInfo{
+ Fs: NewFs(afero.NewOsFs(), root, true),
+ Path: "/root/downloads",
+ }
+
+ got := file.RealPath()
+ want := filepath.Join(root, "root", "downloads")
+ if got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+}
diff --git a/files/scoped.go b/files/scoped.go
index eef6378b..97ce32d0 100644
--- a/files/scoped.go
+++ b/files/scoped.go
@@ -25,6 +25,11 @@ var (
_ afero.Lstater = (*ScopedFs)(nil)
)
+// maxSymlinkHops bounds how many dangling symlinks within() will follow before
+// giving up, so a pathological chain cannot loop forever. It mirrors the kernel
+// MAXSYMLINKS limit; the operation is rejected once the bound is exceeded.
+const maxSymlinkHops = 255
+
func NewScopedFs(source afero.Fs, path string) *ScopedFs {
if s, ok := source.(*ScopedFs); ok {
source = s.base
@@ -32,8 +37,31 @@ func NewScopedFs(source afero.Fs, path string) *ScopedFs {
return &ScopedFs{base: afero.NewBasePathFs(source, path).(*afero.BasePathFs)}
}
-// Base returns the underlying *afero.BasePathFs.
-func (s *ScopedFs) Base() *afero.BasePathFs { return s.base }
+// NewFs builds a user filesystem rooted at path. When followExternal is true it
+// returns a bare BasePathFs, so symlinks whose target resolves outside the scope
+// are followed; otherwise it returns a ScopedFs that refuses to follow them.
+func NewFs(source afero.Fs, path string, followExternal bool) afero.Fs {
+ if followExternal {
+ return afero.NewBasePathFs(source, path)
+ }
+ return NewScopedFs(source, path)
+}
+
+// BasePath returns the underlying *afero.BasePathFs of a user filesystem built
+// by NewFs, whether it is a *ScopedFs or a bare *afero.BasePathFs, or nil if it
+// is neither.
+func BasePath(fs afero.Fs) *afero.BasePathFs {
+ switch f := fs.(type) {
+ case *ScopedFs:
+ return f.BasePathFs()
+ case *afero.BasePathFs:
+ return f
+ }
+ return nil
+}
+
+// BasePathFs returns the underlying *afero.BasePathFs.
+func (s *ScopedFs) BasePathFs() *afero.BasePathFs { return s.base }
// RealPath resolves a scoped path to the real on-disk path by delegating to
// the underlying BasePathFs. This is needed by callers that need the actual
@@ -61,13 +89,10 @@ func (s *ScopedFs) guard(name string) error {
//
// Paths that do not exist yet (e.g. a brand-new file being created) are
// validated against their nearest existing ancestor, so legitimate new files
-// are always allowed.
-//
-// Note: a dangling symlink whose target does not yet exist resolves to its
-// containing directory and is therefore allowed; writing through such a link
-// could still create a file outside the scope. This is treated as best-effort
-// and relies on rejecting existing escaping symlinks, which covers the
-// disclosure and overwrite vectors.
+// are always allowed. A dangling symlink — a link whose target does not exist
+// yet — is the exception: it is followed to where it points and validated
+// there, so a write cannot dereference the link to create a file outside the
+// scope.
func (s *ScopedFs) within(p string) (bool, error) {
root, err := filepath.EvalSymlinks(afero.FullBaseFsPath(s.base, "/"))
if err != nil {
@@ -76,12 +101,43 @@ func (s *ScopedFs) within(p string) (bool, error) {
target := afero.FullBaseFsPath(s.base, p)
resolved, err := filepath.EvalSymlinks(target)
- for errors.Is(err, fs.ErrNotExist) {
- parent := filepath.Dir(target)
- if parent == target {
- break
+ // When target does not resolve, work out where the operation would actually
+ // land. A non-existent regular path resolves to the file that would be
+ // created inside its containing directory, so walk up to the nearest
+ // existing ancestor and validate that. But when target itself is a dangling
+ // symlink, follow it one level instead: validating its lexical parent would
+ // wrongly accept a link pointing outside the scope, letting a write follow
+ // the link and create the file out of bounds.
+ for hops := 0; errors.Is(err, fs.ErrNotExist); {
+ if fi, lerr := os.Lstat(target); lerr == nil && fi.Mode()&os.ModeSymlink != 0 {
+ hops++
+ if hops > maxSymlinkHops {
+ return false, os.ErrPermission
+ }
+ dest, rerr := os.Readlink(target)
+ if rerr != nil {
+ return false, rerr
+ }
+ if !filepath.IsAbs(dest) {
+ // Resolve the link relative to the directory that really contains
+ // it, not its lexical parent: a symlinked ancestor could otherwise
+ // shift the computed target back into scope while the real write
+ // lands outside it. The parent is guaranteed to resolve here
+ // because os.Lstat above already traversed it.
+ base, berr := filepath.EvalSymlinks(filepath.Dir(target))
+ if berr != nil {
+ return false, berr
+ }
+ dest = filepath.Join(base, dest)
+ }
+ target = filepath.Clean(dest)
+ } else {
+ parent := filepath.Dir(target)
+ if parent == target {
+ break
+ }
+ target = parent
}
- target = parent
resolved, err = filepath.EvalSymlinks(target)
}
if err != nil {
@@ -136,10 +192,16 @@ func (s *ScopedFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File
}
func (s *ScopedFs) Remove(name string) error {
+ if err := s.guard(name); err != nil {
+ return err
+ }
return s.base.Remove(name)
}
func (s *ScopedFs) RemoveAll(path string) error {
+ if err := s.guard(path); err != nil {
+ return err
+ }
return s.base.RemoveAll(path)
}
diff --git a/frontend/src/components/prompts/Share.vue b/frontend/src/components/prompts/Share.vue
index 282902fb..c51904d1 100644
--- a/frontend/src/components/prompts/Share.vue
+++ b/frontend/src/components/prompts/Share.vue
@@ -38,7 +38,7 @@
class="action"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')"
- :disabled="!!link.password_hash"
+ :disabled="!!link.hasPassword"
@click="copyToClipboard(buildDownloadLink(link))"
>
content_paste_go
diff --git a/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts b/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts
index cc138329..710baf0d 100644
--- a/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts
+++ b/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts
@@ -71,7 +71,9 @@ const event = {
describe("copy and move conflict prompts", () => {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(checkConflict).mockResolvedValue(conflict);
+ vi.mocked(checkConflict).mockResolvedValue(
+ conflict as ConflictingResource[]
+ );
});
it("waits for copy conflict detection before calling the copy API", async () => {
diff --git a/frontend/src/css/epubReader.css b/frontend/src/css/epubReader.css
index a575fb27..c8ceb110 100644
--- a/frontend/src/css/epubReader.css
+++ b/frontend/src/css/epubReader.css
@@ -29,6 +29,13 @@
.epub-reader .tocButton {
color: var(--text);
+ /*
+ * vue-reader positions the TOC toggle at top: 10px, which lands under the
+ * fixed 4em header bar (see .header) — so clicks hit the header's Close
+ * button instead of opening the table of contents. Push it just below the
+ * header, tracking the header's height so it stays clear if that changes.
+ */
+ top: calc(4em + 10px);
}
.epub-reader .tocButton.tocButtonExpanded {
diff --git a/frontend/src/i18n/ko.json b/frontend/src/i18n/ko.json
index 1accf5f9..fd4170d5 100644
--- a/frontend/src/i18n/ko.json
+++ b/frontend/src/i18n/ko.json
@@ -29,8 +29,8 @@
"rename": "이름 바꾸기",
"replace": "대체",
"reportIssue": "문제 보고",
- "resumeTransfer": "Resume previous transfer",
- "resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
+ "resumeTransfer": "이전 전송을 재개",
+ "resumeTransferTooltip": "서버에 있는 파일 중 전송이 중단되었을 가능성이 있는 작은 파일을 제외하고 충돌하는 모든 파일을 건너뜁니다.",
"save": "저장",
"schedule": "일정",
"search": "검색",
@@ -283,7 +283,7 @@
"currentPassword": "현재 비밀번호"
},
"sidebar": {
- "diskUsed": "{used} of {total} used",
+ "diskUsed": "{total} 중 {used} 사용",
"help": "도움말",
"hugoNew": "Hugo New",
"login": "로그인",
diff --git a/frontend/src/i18n/pt-pt.json b/frontend/src/i18n/pt-pt.json
index 2c04e8aa..721a759b 100644
--- a/frontend/src/i18n/pt-pt.json
+++ b/frontend/src/i18n/pt-pt.json
@@ -238,7 +238,7 @@
"newPasswordConfirm": "Confirmar a sua palavra-passe",
"newUser": "Novo Utilizador",
"password": "Palavra-passe",
- "passwordUpdated": "Palavras-passe não coincidem!",
+ "passwordUpdated": "Palavra-passe atualizada!",
"path": "Caminho",
"perm": {
"create": "Criar ficheiros e pastas",
diff --git a/frontend/src/i18n/pt.json b/frontend/src/i18n/pt.json
deleted file mode 100644
index d0523251..00000000
--- a/frontend/src/i18n/pt.json
+++ /dev/null
@@ -1,309 +0,0 @@
-{
- "buttons": {
- "cancel": "Cancelar",
- "clear": "Limpar",
- "close": "Fechar",
- "continue": "Continuar",
- "copy": "Copiar",
- "copyFile": "Copiar ficheiro",
- "copyToClipboard": "Copiar",
- "copyDownloadLinkToClipboard": "Copy download link to clipboard",
- "create": "Criar",
- "delete": "Eliminar",
- "download": "Descarregar",
- "file": "File",
- "folder": "Folder",
- "fullScreen": "Toggle full screen",
- "hideDotfiles": "Hide dotfiles",
- "info": "Info",
- "more": "Mais",
- "move": "Mover",
- "moveFile": "Mover ficheiro",
- "new": "Novo",
- "next": "Próximo",
- "ok": "OK",
- "permalink": "Obter link permanente",
- "previous": "Anterior",
- "preview": "Preview",
- "publish": "Publicar",
- "rename": "Alterar nome",
- "replace": "Substituir",
- "reportIssue": "Reportar erro",
- "resumeTransfer": "Resume previous transfer",
- "resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
- "save": "Guardar",
- "schedule": "Agendar",
- "search": "Pesquisar",
- "select": "Selecionar",
- "selectMultiple": "Selecionar vários",
- "share": "Partilhar",
- "shell": "Alternar shell",
- "submit": "Submit",
- "switchView": "Alterar vista",
- "toggleSidebar": "Alternar barra lateral",
- "update": "Atualizar",
- "upload": "Enviar",
- "openFile": "Open file",
- "openDirect": "View raw",
- "discardChanges": "Discard",
- "stopSearch": "Stop searching",
- "saveChanges": "Save changes",
- "editAsText": "Edit as Text",
- "increaseFontSize": "Increase font size",
- "decreaseFontSize": "Decrease font size",
- "overrideAll": "Replace all files in destination folder",
- "skipAll": "Skip all conflicting files",
- "renameAll": "Rename all files (create a copy)",
- "singleDecision": "Decide for each conflicting file"
- },
- "download": {
- "downloadFile": "Descarregar ficheiro",
- "downloadFolder": "Descarregar pasta",
- "downloadSelected": "Download Selected"
- },
- "upload": {
- "abortUpload": "Are you sure you wish to abort?"
- },
- "errors": {
- "forbidden": "Não tem permissões para aceder a isto",
- "internal": "Algo correu bastante mal.",
- "notFound": "Esta localização não é alcançável.",
- "connection": "The server can't be reached."
- },
- "files": {
- "body": "Corpo",
- "closePreview": "Fechar pré-visualização",
- "files": "Ficheiros",
- "folders": "Pastas",
- "home": "Início",
- "lastModified": "Última alteração",
- "loading": "A carregar...",
- "lonely": "Sinto-me sozinho...",
- "metadata": "Metadados",
- "multipleSelectionEnabled": "Seleção múltipla ativada",
- "name": "Nome",
- "size": "Tamanho",
- "sortByLastModified": "Ordenar pela última alteração",
- "sortByName": "Ordenar pelo nome",
- "sortBySize": "Ordenar pelo tamanho",
- "noPreview": "Preview is not available for this file.",
- "csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.",
- "csvLoadFailed": "Failed to load CSV file.",
- "showingRows": "Showing {count} row(s)",
- "columnSeparator": "Column Separator",
- "csvSeparators": {
- "comma": "Comma (,)",
- "semicolon": "Semicolon (;)",
- "both": "Both (,) and (;)"
- },
- "fileEncoding": "File Encoding"
- },
- "help": {
- "click": "selecionar pasta ou ficheiro",
- "ctrl": {
- "click": "selecionar várias pastas e ficheiros",
- "f": "pesquisar",
- "s": "guardar um ficheiro ou descarrega a pasta em que está a navegar"
- },
- "del": "eliminar os ficheiros selecionados",
- "doubleClick": "abrir pasta ou ficheiro",
- "esc": "limpar seleção e/ou fechar menu",
- "f1": "esta informação",
- "f2": "alterar nome do ficheiro",
- "help": "Ajuda"
- },
- "login": {
- "createAnAccount": "Criar uma conta",
- "loginInstead": "Já tenho uma conta",
- "password": "Palavra-passe",
- "passwordConfirm": "Confirmação da palavra-passe",
- "passwordsDontMatch": "As palavras-passe não coincidem",
- "signup": "Registar",
- "submit": "Entrar na conta",
- "username": "Nome de utilizador",
- "usernameTaken": "O nome de utilizador já está registado",
- "wrongCredentials": "Dados errados",
- "passwordTooShort": "Password must be at least {min} characters",
- "logout_reasons": {
- "inactivity": "You have been logged out due to inactivity."
- }
- },
- "permanent": "Permanente",
- "prompts": {
- "copy": "Copiar",
- "copyMessage": "Escolha um lugar para onde copiar os ficheiros:",
- "currentlyNavigating": "A navegar em:",
- "deleteMessageMultiple": "Quer eliminar {count} ficheiro(s)?",
- "deleteMessageSingle": "Quer eliminar esta pasta/ficheiro?",
- "deleteMessageShare": "Are you sure you wish to delete this share({path})?",
- "deleteUser": "Are you sure you want to delete this user?",
- "deleteTitle": "Eliminar ficheiros",
- "displayName": "Nome:",
- "download": "Descarregar ficheiros",
- "downloadMessage": "Escolha o formato do ficheiro que quer descarregar.",
- "error": "Algo correu mal",
- "fileInfo": "Informação do ficheiro",
- "filesSelected": "{count} ficheiros selecionados.",
- "lastModified": "Última alteração",
- "move": "Mover",
- "moveMessage": "Escolha uma nova casa para os seus ficheiros/pastas:",
- "newArchetype": "Criar um novo post baseado num \"archetype\". O seu ficheiro será criado na pasta \"content\".",
- "newDir": "Nova pasta",
- "newDirMessage": "Escreva o nome da nova pasta.",
- "newFile": "Novo ficheiro",
- "newFileMessage": "Escreva o nome do novo ficheiro.",
- "numberDirs": "Número de pastas",
- "numberFiles": "Número de ficheiros",
- "rename": "Alterar nome",
- "renameMessage": "Insira um novo nome para",
- "replace": "Substituir",
- "replaceMessage": "Já existe um ficheiro com nome igual a um dos que está a tentar enviar. Quer substituí-lo?\n",
- "schedule": "Agendar",
- "scheduleMessage": "Escolha uma data para publicar este post.",
- "show": "Mostrar",
- "size": "Tamanho",
- "upload": "Upload",
- "uploadFiles": "Uploading {files} files...",
- "uploadMessage": "Select an option to upload.",
- "optionalPassword": "Optional password",
- "resolution": "Resolution",
- "discardEditorChanges": "Are you sure you wish to discard the changes you've made?",
- "replaceOrSkip": "Replace or skip files",
- "resolveConflict": "Which files do you want to keep?",
- "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.",
- "fastConflictResolve": "The destination folder there are {count} files with same name.",
- "uploadingFiles": "Uploading files",
- "filesInOrigin": "Files in origin",
- "filesInDest": "Files in destination",
- "override": "Overwrite",
- "skip": "Skip",
- "forbiddenError": "Forbidden Error",
- "currentPassword": "Your password",
- "currentPasswordMessage": "Enter your password to validate this action."
- },
- "search": {
- "images": "Imagens",
- "music": "Música",
- "pdf": "PDF",
- "pressToSearch": "Tecla Enter para pesquisar...",
- "search": "Pesquisar...",
- "typeToSearch": "Escrever para pesquisar...",
- "types": "Tipos",
- "video": "Vídeos"
- },
- "settings": {
- "aceEditorTheme": "Ace editor theme",
- "admin": "Admin",
- "administrator": "Administrador",
- "allowCommands": "Executar comandos",
- "allowEdit": "Editar, renomear e eliminar ficheiros ou pastas",
- "allowNew": "Criar novos ficheiros e pastas",
- "allowPublish": "Publicar novas páginas e conteúdos",
- "allowSignup": "Permitir que os utilizadores criem contas",
- "hideLoginButton": "Hide the login button from public pages",
- "avoidChanges": "(deixe em branco para manter)",
- "branding": "Marca",
- "brandingDirectoryPath": "Caminho da pasta de marca",
- "brandingHelp": "Pode personalizar a aparência do seu Navegador de Ficheiros, alterar o nome, substituindo o logótipo, adicionando estilos personalizados e mesmo desativando links externos para o GitHub.\nPara mais informações sobre marca personalizada, por favor veja {0}.",
- "changePassword": "Alterar palavra-passe",
- "commandRunner": "Execução de comandos",
- "commandRunnerHelp": "Aqui pode definir comandos que são executados nos eventos nomeados. Tem de escrever um por linha. As variáveis de ambiente {0} e {1} estarão disponíveis, sendo {0} relativo a {1}. Para mais informações sobre esta funcionalidade e as variáveis de ambiente, veja {2}.",
- "commandsUpdated": "Comandos atualizados!",
- "createUserDir": "Criar automaticamente a pasta de início ao adicionar um novo utilizador",
- "minimumPasswordLength": "Minimum password length",
- "tusUploads": "Chunked Uploads",
- "tusUploadsHelp": "File Browser supports chunked file uploads, allowing for the creation of efficient, reliable, resumable and chunked file uploads even on unreliable networks.",
- "tusUploadsChunkSize": "Indicates to maximum size of a request (direct uploads will be used for smaller uploads). You may input a plain integer denoting byte size input or a string like 10MB, 1GB etc.",
- "tusUploadsRetryCount": "Number of retries to perform if a chunk fails to upload.",
- "userHomeBasePath": "Base path for user home directories",
- "userScopeGenerationPlaceholder": "The scope will be auto generated",
- "createUserHomeDirectory": "Create user home directory",
- "customStylesheet": "Folha de estilos personalizada",
- "defaultUserDescription": "Estas são as configurações padrão para novos utilizadores.",
- "disableExternalLinks": "Desativar links externos (exceto documentação)",
- "disableUsedDiskPercentage": "Disable used disk percentage graph",
- "documentation": "documentação",
- "examples": "Exemplos",
- "executeOnShell": "Executar na shell",
- "executeOnShellDescription": "Por padrão, o Navegador de Ficheiros executa os comandos chamando os seus binários diretamente. Se em vez disso, quiser executá-los numa shell (como Bash ou PowerShell), pode definir isso aqui com os argumentos e bandeiras necessários. Se definido, o comando que executa será anexado como um argumento. Isto aplica-se tanto a comandos do utilizador como a hooks de eventos.",
- "globalRules": "Isto é um conjunto global de regras de permissão e negação. Elas aplicam-se a todos os utilizadores. Pode especificar regras específicas para cada configuração do utilizador para sobreporem-se a estas.",
- "globalSettings": "Configurações globais",
- "hideDotfiles": "Hide dotfiles",
- "insertPath": "Inserir o caminho",
- "insertRegex": "Inserir expressão regular",
- "instanceName": "Nome da instância",
- "language": "Linguagem",
- "lockPassword": "Não permitir que o utilizador altere a palavra-passe",
- "newPassword": "Nova palavra-passe",
- "newPasswordConfirm": "Confirme a nova palavra-passe",
- "newUser": "Novo utilizador",
- "password": "Palavra-passe",
- "passwordUpdated": "Palavra-passe atualizada!",
- "path": "Path",
- "perm": {
- "create": "Criar ficheiros e pastas",
- "delete": "Eliminar ficheiros e pastas",
- "download": "Descarregar",
- "execute": "Executar comandos",
- "modify": "Editar ficheiros",
- "rename": "Alterar o nome ou mover ficheiros e pastas",
- "share": "Share files (require download permission)"
- },
- "permissions": "Permissões",
- "permissionsHelp": "Pode definir o utilizador como administrador ou escolher as permissões manualmente. Se selecionar a opção \"Administrador\", todas as outras opções serão automaticamente selecionadas. A gestão dos utilizadores é um privilégio restringido aos administradores.\n",
- "profileSettings": "Configurações do utilizador",
- "redirectAfterCopyMove": "Redirect to destination after copy/move",
- "ruleExample1": "previne o acesso a qualquer \"dotfile\" (como .git, .gitignore) em qualquer pasta\n",
- "ruleExample2": "bloqueia o acesso ao ficheiro chamado Caddyfile na raiz.",
- "rules": "Regras",
- "rulesHelp": "Aqui pode definir um conjunto de regras para permitir ou bloquear o acesso do utilizador a determinados ficheiros ou pastas. Os ficheiros bloqueados não irão aparecer durante a navegação. Suportamos expressões regulares e os caminhos dos ficheiros devem ser relativos à base do utilizador.\n",
- "scope": "Base",
- "setDateFormat": "Set exact date format",
- "settingsUpdated": "Configurações atualizadas!",
- "shareDuration": "Share Duration",
- "shareManagement": "Share Management",
- "shareDeleted": "Share deleted!",
- "singleClick": "Use single clicks to open files and directories",
- "themes": {
- "default": "System default",
- "dark": "Dark",
- "light": "Light",
- "title": "Theme"
- },
- "user": "Utilizador",
- "userCommands": "Comandos",
- "userCommandsHelp": "Uma lista, separada com espaços, de comandos disponíveis para este utilizados. Exemplo:",
- "userCreated": "Utilizador criado!",
- "userDefaults": "Configurações padrão do utilizador",
- "userDeleted": "Utilizador eliminado!",
- "userManagement": "Gestão de utilizadores",
- "userUpdated": "Utilizador atualizado!",
- "username": "Nome de utilizador",
- "users": "Utilizadores",
- "currentPassword": "Your Current Password"
- },
- "sidebar": {
- "diskUsed": "{used} of {total} used",
- "help": "Ajuda",
- "hugoNew": "Hugo New",
- "login": "Entrar",
- "logout": "Sair",
- "myFiles": "Meus ficheiros",
- "newFile": "Novo ficheiro",
- "newFolder": "Nova pasta",
- "preview": "Pré-visualizar",
- "settings": "Configurações",
- "signup": "Registar",
- "siteSettings": "Configurações do site"
- },
- "success": {
- "linkCopied": "Link copiado!"
- },
- "time": {
- "days": "Dias",
- "hours": "Horas",
- "minutes": "Minutos",
- "seconds": "Segundos",
- "unit": "Unidades de tempo"
- }
-}
diff --git a/frontend/src/i18n/zh-cn.json b/frontend/src/i18n/zh-cn.json
index 4d021be3..e03a6d98 100644
--- a/frontend/src/i18n/zh-cn.json
+++ b/frontend/src/i18n/zh-cn.json
@@ -28,104 +28,104 @@
"publish": "发布",
"rename": "重命名",
"replace": "替换",
- "reportIssue": "报告问题",
- "resumeTransfer": "Resume previous transfer",
- "resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
+ "reportIssue": "反馈问题",
+ "resumeTransfer": "恢复之前的传输任务",
+ "resumeTransferTooltip": "跳过所有同名冲突文件;服务器端文件更小则判定为传输中断,保留该文件并继续传输。",
"save": "保存",
"schedule": "计划",
"search": "搜索",
"select": "选择",
- "selectMultiple": "选择多个",
+ "selectMultiple": "多选",
"share": "分享",
- "shell": "激活 Shell",
+ "shell": "切换终端",
"submit": "提交",
- "switchView": "切换显示方式",
+ "switchView": "切换视图",
"toggleSidebar": "切换侧边栏",
"update": "更新",
"upload": "上传",
"openFile": "打开文件",
- "openDirect": "View raw",
- "discardChanges": "放弃更改",
+ "openDirect": "查看原始内容",
+ "discardChanges": "放弃",
"stopSearch": "停止搜索",
"saveChanges": "保存更改",
- "editAsText": "以文本形式编辑",
- "increaseFontSize": "增大字体大小",
- "decreaseFontSize": "减小字体大小",
- "overrideAll": "Replace all files in destination folder",
- "skipAll": "Skip all conflicting files",
- "renameAll": "Rename all files (create a copy)",
- "singleDecision": "Decide for each conflicting file"
+ "editAsText": "以文本模式编辑",
+ "increaseFontSize": "放大字体",
+ "decreaseFontSize": "缩小字体",
+ "overrideAll": "替换目标文件夹中的所有文件",
+ "skipAll": "跳过所有冲突文件",
+ "renameAll": "全部重命名(创建副本)",
+ "singleDecision": "逐个处理冲突文件"
},
"download": {
"downloadFile": "下载文件",
"downloadFolder": "下载文件夹",
- "downloadSelected": "下载已选"
+ "downloadSelected": "下载选中项"
},
"upload": {
"abortUpload": "你确定要中止吗?"
},
"errors": {
- "forbidden": "你无权限访问",
- "internal": "服务器出了点问题。",
- "notFound": "找不到文件。",
- "connection": "无法连接到服务器。"
+ "forbidden": "权限不足,拒绝访问",
+ "internal": "服务器内部错误",
+ "notFound": "该位置无法访问。",
+ "connection": "无法连接服务器"
},
"files": {
"body": "内容",
"closePreview": "关闭预览",
"files": "文件",
"folders": "文件夹",
- "home": "主页",
- "lastModified": "最后修改",
+ "home": "首页",
+ "lastModified": "修改时间",
"loading": "加载中...",
- "lonely": "这里没有任何文件...",
+ "lonely": "暂无任何文件...",
"metadata": "元数据",
- "multipleSelectionEnabled": "多选模式已开启",
+ "multipleSelectionEnabled": "已开启多选模式",
"name": "名称",
"size": "大小",
- "sortByLastModified": "按最后修改时间排序",
+ "sortByLastModified": "按修改时间排序",
"sortByName": "按名称排序",
"sortBySize": "按大小排序",
- "noPreview": "此文件无法预览。",
- "csvTooLarge": "CSV文件大到无法预览(>5MB)。请下载查看。",
- "csvLoadFailed": "加载 CSV 文件失败。",
- "showingRows": "正在显示 {count} 行",
+ "noPreview": "该文件暂不支持预览",
+ "csvTooLarge": "CSV 文件过大(>5MB),无法预览,请下载后查看",
+ "csvLoadFailed": "CSV 文件加载失败",
+ "showingRows": "当前显示 {count} 行数据",
"columnSeparator": "列分隔符",
"csvSeparators": {
"comma": "逗号 (,)",
"semicolon": "分号 (;)",
"both": "逗号 (,) 和分号 (;)"
},
- "fileEncoding": "File Encoding"
+ "fileEncoding": "文件编码"
},
"help": {
- "click": "选择文件或文件夹",
+ "click": "选中文件或文件夹",
"ctrl": {
- "click": "选择多个文件或文件夹",
+ "click": "多选文件或文件夹",
"f": "打开搜索框",
"s": "保存文件或下载当前文件夹"
},
"del": "删除所选的文件/文件夹",
"doubleClick": "打开文件/文件夹",
- "esc": "清除已选项或关闭提示信息",
- "f1": "显示该帮助信息",
- "f2": "重命名文件/文件夹",
+ "esc": "取消选中或关闭提示框",
+ "f1": "打开帮助",
+ "f2": "重命名文件",
"help": "帮助"
},
"login": {
- "createAnAccount": "创建用户",
- "loginInstead": "已有用户登录",
+ "createAnAccount": "注册账号",
+ "loginInstead": "已有账号",
"password": "密码",
"passwordConfirm": "确认密码",
- "passwordsDontMatch": "密码不一致",
+ "passwordsDontMatch": "两次输入的密码不一致",
"signup": "注册",
"submit": "登录",
"username": "用户名",
"usernameTaken": "用户名已经被使用",
"wrongCredentials": "用户名或密码错误",
- "passwordTooShort": "密码必须至少包含 {min} 个字符",
+ "passwordTooShort": "密码长度至少为 {min} 位字符",
"logout_reasons": {
- "inactivity": "由于未活动,您已登出。"
+ "inactivity": "因长时间未操作,系统已自动登出."
}
},
"permanent": "永久",
@@ -136,58 +136,58 @@
"deleteMessageMultiple": "你确定要删除这 {count} 个文件吗?",
"deleteMessageSingle": "你确定要删除这个文件/文件夹吗?",
"deleteMessageShare": "你确定要删除这个分享({path})吗?",
- "deleteUser": "你确定要删除这个用户吗?",
+ "deleteUser": "确定要删除该用户吗?",
"deleteTitle": "删除文件",
"displayName": "名称:",
"download": "下载文件",
- "downloadMessage": "请选择要下载的压缩格式。",
- "error": "出了一点问题...",
+ "downloadMessage": "请选择要下载的压缩包格式。",
+ "error": "出现未知错误...",
"fileInfo": "文件信息",
- "filesSelected": "已选择 {count} 个文件。",
- "lastModified": "最后修改",
+ "filesSelected": "已选中 {count} 个文件",
+ "lastModified": "修改时间",
"move": "移动",
"moveMessage": "请选择目标目录:",
- "newArchetype": "创建一个基于原型的新帖子。你的文件将会创建在内容文件夹中。",
+ "newArchetype": "基于模板新建文档,文件将创建在内容目录中。",
"newDir": "新建文件夹",
"newDirMessage": "请输入新文件夹的名称。",
"newFile": "新建文件",
"newFileMessage": "请输入新文件的名称。",
- "numberDirs": "文件夹数",
- "numberFiles": "文件数",
+ "numberDirs": "文件夹数量",
+ "numberFiles": "文件数量",
"rename": "重命名",
- "renameMessage": "请输入新名称,旧名称为:",
+ "renameMessage": "请输入新名称",
"replace": "替换",
- "replaceMessage": "你尝试上传的文件中有一个与现有文件的名称存在冲突。是否替换现有的同名文件?\n",
+ "replaceMessage": "你上传的文件与已有文件重名,是否跳过该文件继续上传,或是替换原有文件?\n",
"schedule": "计划",
- "scheduleMessage": "请选择发布这篇帖子的日期与时间。",
- "show": "点击以显示",
+ "scheduleMessage": "请选择文档发布的日期和时间",
+ "show": "显示",
"size": "大小",
"upload": "上传",
"uploadFiles": "正在上传 {files} ...",
- "uploadMessage": "选择上传选项。",
- "optionalPassword": "密码(选填,不填即无密码)",
+ "uploadMessage": "请选择上传选项",
+ "optionalPassword": "密码(选填,留空则无需密码)",
"resolution": "分辨率",
- "discardEditorChanges": "你确定要放弃所做的更改吗?",
- "replaceOrSkip": "Replace or skip files",
- "resolveConflict": "Which files do you want to keep?",
- "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.",
- "fastConflictResolve": "The destination folder there are {count} files with same name.",
- "uploadingFiles": "Uploading files",
- "filesInOrigin": "Files in origin",
- "filesInDest": "Files in destination",
- "override": "Overwrite",
- "skip": "Skip",
- "forbiddenError": "Forbidden Error",
- "currentPassword": "Your password",
- "currentPasswordMessage": "Enter your password to validate this action."
+ "discardEditorChanges": "是否放弃已修改的内容?",
+ "replaceOrSkip": "替换或跳过文件",
+ "resolveConflict": "保留哪些文件?",
+ "singleConflictResolve": "若同时保留两个版本,复制的文件会自动追加序号。",
+ "fastConflictResolve": "目标目录内存在 {count} 个同名文件",
+ "uploadingFiles": "正在上传文件",
+ "filesInOrigin": "源文件",
+ "filesInDest": "目标文件",
+ "override": "覆盖",
+ "skip": "跳过",
+ "forbiddenError": "权限错误",
+ "currentPassword": "当前密码",
+ "currentPasswordMessage": "请输入当前密码以验证操作"
},
"search": {
"images": "图像",
"music": "音乐",
"pdf": "PDF",
- "pressToSearch": "按回车以搜索...",
+ "pressToSearch": "按下回车开始搜索...",
"search": "搜索...",
- "typeToSearch": "输入以搜索...",
+ "typeToSearch": "输入内容进行搜索...",
"types": "类型",
"video": "视频"
},
@@ -195,47 +195,47 @@
"aceEditorTheme": "Ace编辑器主题",
"admin": "管理员",
"administrator": "管理员",
- "allowCommands": "执行命令(Shell 命令)",
+ "allowCommands": "允许执行终端命令",
"allowEdit": "编辑、重命名或删除文件/文件夹",
"allowNew": "创建新文件和文件夹",
"allowPublish": "发布新的帖子与页面",
"allowSignup": "允许用户注册",
"hideLoginButton": "从公开页面隐藏登录按钮",
- "avoidChanges": "(留空以避免更改)",
+ "avoidChanges": "(留空则不修改)",
"branding": "品牌",
- "brandingDirectoryPath": "品牌信息文件夹路径",
- "brandingHelp": "你可以通过改变实例名称,更换 Logo,加入自定义样式,甚至禁用到 Github 的外部链接来自定义 File Browser 的外观和感觉。\n想获得更多信息,请查看 {0}。",
+ "brandingDirectoryPath": "品牌资源目录路径",
+ "brandingHelp": "你可以修改站点名称、更换Logo、添加自定义样式,也可以禁用外部GitHub链接,来自定义本文件管理器界面。\n详情请查看 {0}。",
"changePassword": "更改密码",
"commandRunner": "命令执行器",
- "commandRunnerHelp": "你可以在此设置在下列事件中执行的命令。每行必须写一条命令。可以在命令中使用环境变量 {0} 和 {1},使 {0} 与 {1} 相关联。关于此功能和可用环境变量的更多信息,请阅读 {2}。",
+ "commandRunnerHelp": "在此设置事件触发命令,单行单条。环境变量 {0}、{1} 可用,{0} 相对 {1} 生效。功能与变量详情请参考 {2}。",
"commandsUpdated": "命令已更新!",
- "createUserDir": "在添加新用户的同时自动创建用户的主目录",
+ "createUserDir": "新增用户时自动创建用户主目录",
"minimumPasswordLength": "最小密码长度",
"tusUploads": "分块上传",
- "tusUploadsHelp": "File Browser 支持分块上传,在不佳的网络下也可进行高效、可靠、可续的文件上传",
- "tusUploadsChunkSize": "分块上传大小,例如 10MB 或 1GB",
+ "tusUploadsHelp": "支持文件分片上传,网络不佳时仍可稳定传输,并支持断点续传。",
+ "tusUploadsChunkSize": "分块大小(例如:10MB、1GB);小于该值的文件将直接上传。",
"tusUploadsRetryCount": "分块上传失败时的重试次数",
"userHomeBasePath": "用户主目录的路径",
"userScopeGenerationPlaceholder": "自动生成目录范围",
"createUserHomeDirectory": "创建用户主目录",
- "customStylesheet": "自定义样式表(CSS)",
- "defaultUserDescription": "这些是新用户的默认设置。",
+ "customStylesheet": "自定义样式表",
+ "defaultUserDescription": "以下为新用户的默认配置",
"disableExternalLinks": "禁止外部链接(帮助文档除外)",
"disableUsedDiskPercentage": "禁用磁盘已用空间展示",
"documentation": "帮助文档",
"examples": "示例",
- "executeOnShell": "在 Shell 中执行",
- "executeOnShellDescription": "默认情况下,File Browser 通过直接调用命令的二进制包来执行命令,如果想在 Shell中 执行(如 Bash 或 PowerShell),你可以在这里定义所使用的 Shell 和参数。设置后,你所执行的命令会作为参数追加。本设置对用户命令和事件钩子都生效。",
- "globalRules": "这是全局允许与禁止规则。它们作用于所有用户。你可以给每个用户定义单独的特殊规则来覆盖全局规则。",
+ "executeOnShell": "通过终端执行",
+ "executeOnShellDescription": "默认直接调用程序执行命令。如需通过终端运行(如Bash、PowerShell 等),可在此配置解释器、参数及选项,执行命令将自动作为参数传入。本设置对用户命令和事件钩子均生效。",
+ "globalRules": "全局访问规则,对所有用户生效。也可单独为用户设置规则来覆盖全局规则。",
"globalSettings": "全局设置",
"hideDotfiles": "不显示隐藏文件",
"insertPath": "插入路径",
"insertRegex": "插入正则表达式",
- "instanceName": "实例名称",
+ "instanceName": "站点名称",
"language": "语言",
"lockPassword": "禁止用户修改密码",
- "newPassword": "你的新密码",
- "newPasswordConfirm": "再次输入以确认你的新密码",
+ "newPassword": "新密码",
+ "newPasswordConfirm": "再次输入新密码",
"newUser": "新建用户",
"password": "密码",
"passwordUpdated": "密码已更新!",
@@ -247,23 +247,23 @@
"execute": "执行命令",
"modify": "编辑",
"rename": "重命名或移动文件和文件夹",
- "share": "Share files (require download permission)"
+ "share": "分享文件(需开启下载权限)"
},
"permissions": "权限",
"permissionsHelp": "你可以将该用户设置为管理员或单独选择各项权限。如果你选择了“管理员”,则其他的选项会被自动选中,同时该用户可以管理其他用户。\n",
"profileSettings": "个人设置",
"redirectAfterCopyMove": "复制/移动后转到目标位置",
- "ruleExample1": "阻止用户访问所有文件夹下任何以 . 开头的文件(隐藏文件, 例如: .git, .gitignore)。\n",
- "ruleExample2": "阻止用户访问其目录范围的根目录下名为 Caddyfile 的文件。",
+ "ruleExample1": "禁止用户访问所有目录下以 . 开头的隐藏文件(隐藏文件, 例如: .git, .gitignore)。\n",
+ "ruleExample2": "禁止用户访问个人根目录下的 Caddyfile 文件。",
"rules": "规则",
- "rulesHelp": "你可以为该用户制定一组黑名单或白名单式的规则,被屏蔽的文件将不会显示在列表中,用户也无权限访问,支持正则表达式和相对于用户范围的路径。\n",
+ "rulesHelp": "可为用户配置黑白名单规则,被限制的文件将隐藏且无法访问。支持正则表达式、相对用户目录的路径写法。\n",
"scope": "目录范围",
"setDateFormat": "显示精确的日期格式",
"settingsUpdated": "设置已更新!",
"shareDuration": "分享期限",
"shareManagement": "分享管理",
"shareDeleted": "分享已删除!",
- "singleClick": "使用单击来打开文件和文件夹",
+ "singleClick": "单击打开文件/文件夹",
"themes": {
"default": "系统默认",
"dark": "深色",
@@ -271,30 +271,30 @@
"title": "主题"
},
"user": "用户",
- "userCommands": "用户命令(Shell 命令)",
- "userCommandsHelp": "指定该用户可以执行的命令(Shell 命令),用空格分隔。例如:\n",
+ "userCommands": "可用终端命令",
+ "userCommandsHelp": "指定该用户可执行的终端命令,多个命令用空格分隔。示例:\n",
"userCreated": "用户已创建!",
"userDefaults": "用户默认设置",
"userDeleted": "用户已删除!",
"userManagement": "用户管理",
- "userUpdated": "用户已更新!",
+ "userUpdated": "用户信息已更新!",
"username": "用户名",
"users": "用户",
- "currentPassword": "您当前的密码"
+ "currentPassword": "当前密码"
},
"sidebar": {
- "diskUsed": "{used} of {total} used",
+ "diskUsed": "已使用 {used} / 总容量 {total}",
"help": "帮助",
"hugoNew": "Hugo 新建",
"login": "登录",
- "logout": "登出",
+ "logout": "退出",
"myFiles": "我的文件",
"newFile": "新建文件",
"newFolder": "新建文件夹",
"preview": "预览",
"settings": "设置",
"signup": "注册",
- "siteSettings": "网站设置"
+ "siteSettings": "站点设置"
},
"success": {
"linkCopied": "链接已复制!"
diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts
index c1592a21..f4864724 100644
--- a/frontend/src/types/api.d.ts
+++ b/frontend/src/types/api.d.ts
@@ -25,7 +25,7 @@ interface Share {
path: string;
expire?: any;
userID?: number;
- token?: string;
+ hasPassword?: boolean;
username?: string;
}
diff --git a/frontend/src/utils/__tests__/check-conflict.test.ts b/frontend/src/utils/__tests__/check-conflict.test.ts
index 37a4e69b..c60b33ab 100644
--- a/frontend/src/utils/__tests__/check-conflict.test.ts
+++ b/frontend/src/utils/__tests__/check-conflict.test.ts
@@ -4,6 +4,7 @@ import { files as api } from "@/api";
vi.mock("@/api", () => ({
files: {
+ fetch: vi.fn(),
fetchAll: vi.fn(),
},
}));
@@ -19,8 +20,8 @@ vi.mock("@/stores/layout", () => ({ useLayoutStore: vi.fn() }));
vi.mock("@/stores/upload", () => ({ useUploadStore: vi.fn() }));
vi.mock("@/utils/url", () => ({ default: {} }));
-// A move/copy/drag item carries `name` (raw) and `to` (URL-encoded) but no
-// `fullPath` — mirroring what Move.vue / Copy.vue / ListingItem.vue build.
+// A move/copy/drag item carries name (raw) and to (URL-encoded) but no
+// fullPath - mirroring what Move.vue / Copy.vue / ListingItem.vue build.
function moveItem(name: string, dest: string, size = 12) {
return {
from: `/files/source/${encodeURIComponent(name)}`,
@@ -167,29 +168,46 @@ describe("checkConflict", () => {
expect(conflicts[0].name).toBe("/target/folder/deep/file.txt");
});
- // Copy/move stats the whole destination, so a same-named directory is a
- // conflict in its own right (regression for the directory case of #5957).
- it("reports a directory conflict for copy/move (includeDirectories)", async () => {
- vi.mocked(api.fetchAll).mockResolvedValue([
- {
- path: "/target/folder",
- name: "folder",
- size: 0,
- modified: "2026-06-04T00:00:00Z",
- isDir: true,
- },
- ]);
+ // Copy/move only needs the target directory's direct children. A recursive
+ // walk can make the UI look frozen on large destinations (regression #6005).
+ it("checks only the direct destination listing for copy/move", async () => {
+ vi.mocked(api.fetch).mockResolvedValue({
+ items: [
+ {
+ path: "/target/file.txt",
+ name: "file.txt",
+ size: 10,
+ modified: "2026-06-04T00:00:00Z",
+ isDir: false,
+ },
+ {
+ path: "/target/folder",
+ name: "folder",
+ size: 0,
+ modified: "2026-06-04T00:00:00Z",
+ isDir: true,
+ },
+ ],
+ } as Resource);
- const items = [{ ...moveItem("folder", "/files/target/", 0), isDir: true }];
+ const items = [
+ moveItem("file.txt", "/files/target/"),
+ { ...moveItem("folder", "/files/target/", 0), isDir: true },
+ ];
const conflicts = await checkConflict(items, "/files/target/", true);
- expect(conflicts).toHaveLength(1);
- expect(conflicts[0].name).toBe("/target/folder");
+ expect(api.fetch).toHaveBeenCalledWith("/files/target/");
+ expect(api.fetchAll).not.toHaveBeenCalled();
+ expect(conflicts).toHaveLength(2);
+ expect(conflicts.map((conflict) => conflict.name)).toEqual([
+ "/target/file.txt",
+ "/target/folder",
+ ]);
});
// Uploads merge into an existing folder, so the directory itself must not be
- // reported — only the files inside it can conflict.
+ // reported - only the files inside it can conflict.
it("ignores a directory conflict for uploads (default)", async () => {
vi.mocked(api.fetchAll).mockResolvedValue([
{
@@ -220,4 +238,52 @@ describe("checkConflict", () => {
expect(conflicts).toHaveLength(0);
});
+
+ // Regression for #5980: a FileBrowser server running on Windows returns
+ // backslash-separated paths from the recursive listing. Without normalizing
+ // them, the prefix strip and key lookup never match, so the conflict modal is
+ // skipped and the backend returns a bare 409.
+ it("detects a conflict for backslash-separated server paths (Windows)", async () => {
+ vi.mocked(api.fetchAll).mockResolvedValue([
+ {
+ path: "\\target\\file.txt",
+ name: "file.txt",
+ size: 10,
+ modified: "2026-06-04T00:00:00Z",
+ isDir: false,
+ },
+ ]);
+
+ const conflicts = await checkConflict(
+ [moveItem("file.txt", "/files/target/")],
+ "/files/target/"
+ );
+
+ expect(conflicts).toHaveLength(1);
+ });
+
+ it("detects nested conflicts for backslash-separated server paths (Windows)", async () => {
+ vi.mocked(api.fetchAll).mockResolvedValue([
+ {
+ path: "\\target\\folder\\nested file.txt",
+ name: "nested file.txt",
+ size: 10,
+ modified: "2026-06-04T00:00:00Z",
+ isDir: false,
+ },
+ ]);
+
+ const files = [
+ {
+ name: "nested file.txt",
+ size: 12,
+ isDir: false,
+ fullPath: "folder/nested file.txt",
+ },
+ ];
+
+ const conflicts = await checkConflict(files, "/files/target/");
+
+ expect(conflicts).toHaveLength(1);
+ });
});
diff --git a/frontend/src/utils/upload.ts b/frontend/src/utils/upload.ts
index c4a9aab4..fec3c2a6 100644
--- a/frontend/src/utils/upload.ts
+++ b/frontend/src/utils/upload.ts
@@ -21,20 +21,61 @@ function conflictKey(item: UploadEntry): string {
return (item.fullPath || item.name).replace(/^\/+/, "");
}
+type ServerConflictEntry = {
+ path: string;
+ name: string;
+ size: number;
+ modified: string;
+};
+
+async function fetchConflictEntries(
+ basePath: string,
+ includeDirectories: boolean
+): Promise {
+ if (!includeDirectories) {
+ return await api.fetchAll(basePath);
+ }
+
+ const destination = await api.fetch(basePath);
+ return destination.items ?? [];
+}
+
+function conflictPath(entry: ServerConflictEntry): string {
+ return entry.path.replace(/\\/g, "/");
+}
+
+function buildConflictMap(
+ serverEntries: ServerConflictEntry[],
+ basePath: string,
+ includeDirectories: boolean
+): Map {
+ const serverMap = new Map();
+ const normBase = removePrefix(basePath).replace(/\/+$/, "");
+ for (const entry of serverEntries) {
+ // A Windows server may return OS-native backslash separators; normalize to
+ // forward slashes so the prefix strip and key lookup line up.
+ const path = conflictPath(entry);
+ const key = includeDirectories
+ ? entry.name
+ : path.startsWith(normBase)
+ ? path.slice(normBase.length)
+ : path;
+ serverMap.set(key.replace(/^\/+/, ""), entry);
+ }
+
+ return serverMap;
+}
+
/**
* Return the entries from `files` that already exist under `basePath` on the
* server, so the caller can prompt the user to overwrite/rename/skip.
*
- * The whole destination tree is fetched once and indexed by path relative to
- * the destination, then every entry is looked up directly — no need to mirror
- * the upload's folder structure.
- *
* Directory handling differs by action, hence `includeDirectories`:
- * - Upload (false): an existing folder is silently merged, so only the
- * individual files inside it can conflict.
- * - Copy/move (true): the server stats the destination and rejects it whole if
- * a same-named entry exists, so the directory itself is a conflict. The list
- * only holds the top-level items being moved, so each is reported once.
+ * - Upload (false): the destination tree is fetched recursively so nested
+ * file uploads can be checked; existing folders are silently merged.
+ * - Copy/move (true): only the destination directory itself is fetched. These
+ * operations move flat top-level selections, so a same-named direct child is
+ * the only preflight conflict the backend will reject.
*
* @param files - flat upload list to check
* @param basePath - server destination path (e.g. "/files/uploads/")
@@ -47,26 +88,19 @@ export async function checkConflict(
): Promise {
if (files.length === 0) return [];
- let serverEntries: RecursiveEntry[];
+ let serverEntries: ServerConflictEntry[];
try {
- // Single API call: fetch the entire server tree under basePath.
- serverEntries = await api.fetchAll(basePath);
+ serverEntries = await fetchConflictEntries(basePath, includeDirectories);
} catch {
// The destination doesn't exist yet, so nothing can conflict.
return [];
}
- // The server returns paths absolute within the user's scope
- // (e.g. "/uploads/sub/file.txt"). Strip the basePath prefix so the keys line
- // up with each entry's conflictKey, which is relative to the destination.
- const normBase = removePrefix(basePath).replace(/\/+$/, "");
- const serverMap = new Map();
- for (const entry of serverEntries) {
- const rel = entry.path.startsWith(normBase)
- ? entry.path.slice(normBase.length)
- : entry.path;
- serverMap.set(rel.replace(/^\/+/, ""), entry);
- }
+ const serverMap = buildConflictMap(
+ serverEntries,
+ basePath,
+ includeDirectories
+ );
const conflicts: ConflictingResource[] = [];
files.forEach((file, index) => {
@@ -77,7 +111,7 @@ export async function checkConflict(
conflicts.push({
index,
- name: server.path,
+ name: conflictPath(server),
origin: { lastModified: file.file?.lastModified, size: file.size },
dest: { lastModified: server.modified, size: server.size },
checked: ["origin"],
diff --git a/go.mod b/go.mod
index b333522e..042a6a32 100644
--- a/go.mod
+++ b/go.mod
@@ -4,30 +4,30 @@ go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
- github.com/asticode/go-astisub v0.40.0
+ github.com/asticode/go-astisub v0.41.0
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
- github.com/jellydator/ttlcache/v3 v3.4.0
+ github.com/jellydator/ttlcache/v3 v3.4.1
github.com/maruel/natural v1.3.0
github.com/marusama/semaphore/v2 v2.5.0
github.com/mholt/archives v0.1.5
github.com/mitchellh/go-homedir v1.1.0
- github.com/redis/go-redis/v9 v9.20.1
+ github.com/redis/go-redis/v9 v9.21.0
github.com/samber/lo v1.53.0
- github.com/shirou/gopsutil/v4 v4.26.5
+ github.com/shirou/gopsutil/v4 v4.26.6
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce
- go.etcd.io/bbolt v1.4.3
+ go.etcd.io/bbolt v1.5.0
golang.org/x/crypto v0.53.0
- golang.org/x/image v0.42.0
+ golang.org/x/image v0.43.0
golang.org/x/text v0.38.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -35,7 +35,7 @@ require (
require (
github.com/STARRY-S/zip v0.2.3 // indirect
- github.com/andybalholm/brotli v1.2.1 // indirect
+ github.com/andybalholm/brotli v1.2.2 // indirect
github.com/asticode/go-astikit v0.59.0 // indirect
github.com/asticode/go-astits v1.15.0 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
@@ -52,16 +52,16 @@ require (
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
- github.com/golang/geo v0.0.0-20260612074446-f1a45663b0f3 // indirect
+ github.com/golang/geo v0.0.0-20260625163123-7c0e84413537 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/klauspost/compress v1.18.6 // indirect
+ github.com/klauspost/compress v1.19.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.1.1 // indirect
- github.com/nwaples/rardecode/v2 v2.2.3 // indirect
- github.com/pelletier/go-toml/v2 v2.3.1 // indirect
+ github.com/nwaples/rardecode/v2 v2.2.5 // indirect
+ github.com/pelletier/go-toml/v2 v2.4.2 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
diff --git a/go.sum b/go.sum
index c228b345..76e51e7e 100644
--- a/go.sum
+++ b/go.sum
@@ -4,16 +4,16 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
-github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
-github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
+github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astikit v0.59.0 h1:tjbwDym+MTSxqkAhJoHRZmHMXK6Jv4vGx+97FptKH6k=
github.com/asticode/go-astikit v0.59.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
-github.com/asticode/go-astisub v0.40.0 h1:Z8tAXHngjkh/4L9zqt49ru9UdpDTqBIe+80xexKM3/I=
-github.com/asticode/go-astisub v0.40.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
+github.com/asticode/go-astisub v0.41.0 h1:XFPIreVnpi9nPNVRmTdRuq4fr9XzGhgCRwpAaccnShM=
+github.com/asticode/go-astisub v0.41.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/asticode/go-astits v1.15.0 h1:yRyCiUc8Jj4F7clt2GDxHghMpWuFL5rkaLuGUd2/0J4=
github.com/asticode/go-astits v1.15.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
@@ -82,8 +82,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
-github.com/golang/geo v0.0.0-20260612074446-f1a45663b0f3 h1:UlucSQUu9SZdDRlMWv5N/T3Rig9gv615vWvHFKrXHWA=
-github.com/golang/geo v0.0.0-20260612074446-f1a45663b0f3/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
+github.com/golang/geo v0.0.0-20260625163123-7c0e84413537 h1:KeIaDS/+VEy/bhDYjG3Z78dOyLAU4HXcVxmd0WYHJTE=
+github.com/golang/geo v0.0.0-20260625163123-7c0e84413537/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -101,13 +101,13 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
-github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
+github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ=
+github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
-github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
-github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
+github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0 h1:NMpwD2G9JSFOE1/TJjGSo5zG7Yb2bTe7eq1jH+irmeE=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
@@ -134,10 +134,10 @@ github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/nwaples/rardecode/v2 v2.2.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A=
-github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
-github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
-github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY=
+github.com/nwaples/rardecode/v2 v2.2.5/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
+github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
+github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
@@ -146,8 +146,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
-github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
-github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
+github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
+github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -156,8 +156,8 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
-github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
-github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
+github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
+github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -201,8 +201,8 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
-go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
-go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
+go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
+go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -216,8 +216,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
-golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
+golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
+golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
diff --git a/http/auth.go b/http/auth.go
index 4381e86c..b96819b8 100644
--- a/http/auth.go
+++ b/http/auth.go
@@ -102,7 +102,7 @@ func withUser(fn handleFunc) handleFunc {
w.Header().Add("X-Renew-Token", "true")
}
- d.user, err = d.store.Users.Get(d.server.Root, tk.User.ID)
+ d.user, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, tk.User.ID)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -201,6 +201,22 @@ var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int,
return http.StatusInternalServerError, err
}
user.Scope = userHome
+
+ // When home directories are created from the username, distinct usernames
+ // can normalize to the same scope (cleanUsername is many-to-one), which would
+ // silently hand the new user another user's home directory. Reject the signup
+ // if the derived scope is already taken. When CreateUserDir is off, all
+ // signups intentionally share the configured default scope, so this check
+ // does not apply.
+ if d.settings.CreateUserDir {
+ switch _, err := d.store.Users.GetByScope(user.Scope); {
+ case err == nil:
+ return http.StatusConflict, fberrors.ErrExist
+ case !errors.Is(err, fberrors.ErrNotExist):
+ return http.StatusInternalServerError, err
+ }
+ }
+
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
err = d.store.Users.Save(user)
diff --git a/http/auth_test.go b/http/auth_test.go
new file mode 100644
index 00000000..bacff284
--- /dev/null
+++ b/http/auth_test.go
@@ -0,0 +1,71 @@
+package fbhttp
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/asdine/storm/v3"
+
+ "github.com/filebrowser/filebrowser/v2/settings"
+ "github.com/filebrowser/filebrowser/v2/storage/bolt"
+)
+
+// Regression for the username-normalization home-directory collision
+// (GHSA-7rc3-g7h6-22m7): with Signup and CreateUserDir enabled, two distinct
+// usernames that cleanUsername() normalizes to the same directory must not be
+// handed the same home directory. The second registration is rejected.
+func TestSignupRejectsCollidingNormalizedScope(t *testing.T) {
+ root := t.TempDir()
+
+ db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
+ if err != nil {
+ t.Fatalf("failed to open db: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ st, err := bolt.NewStorage(db)
+ if err != nil {
+ t.Fatalf("failed to get storage: %v", err)
+ }
+ if err := st.Settings.Save(&settings.Settings{
+ Key: []byte("test-signing-key"),
+ Signup: true,
+ CreateUserDir: true,
+ UserHomeBasePath: "/users",
+ MinimumPasswordLength: 1,
+ }); err != nil {
+ t.Fatalf("failed to save settings: %v", err)
+ }
+
+ server := &settings.Server{Root: root}
+
+ signup := func(username string) *httptest.ResponseRecorder {
+ body := `{"username":"` + username + `","password":"CollidePw12345!"}`
+ req, _ := http.NewRequest(http.MethodPost, "/signup", strings.NewReader(body))
+ rec := httptest.NewRecorder()
+ handle(signupHandler, "", st, server).ServeHTTP(rec, req)
+ return rec
+ }
+
+ // Victim registers first and gets /users/teamone-x.
+ if rec := signup("teamone-x"); rec.Code != http.StatusOK {
+ t.Fatalf("first signup: expected 200, got %d body=%q", rec.Code, rec.Body.String())
+ }
+
+ // Attacker picks a distinct username that normalizes to the same scope.
+ if rec := signup("teamone/x"); rec.Code != http.StatusConflict {
+ t.Fatalf("VULNERABLE: colliding signup expected 409, got %d body=%q", rec.Code, rec.Body.String())
+ }
+
+ // The shared scope must still be owned solely by the first user.
+ owner, err := st.Users.GetByScope("/users/teamone-x")
+ if err != nil {
+ t.Fatalf("expected first user to own the scope: %v", err)
+ }
+ if owner.Username != "teamone-x" {
+ t.Fatalf("scope owner = %q, want teamone-x", owner.Username)
+ }
+}
diff --git a/http/public.go b/http/public.go
index a882f2f7..211f8a87 100644
--- a/http/public.go
+++ b/http/public.go
@@ -27,7 +27,7 @@ var withHashFile = func(fn handleFunc) handleFunc {
return status, err
}
- user, err := d.store.Users.Get(d.server.Root, link.UserID)
+ user, err := d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, link.UserID)
if err != nil {
return errToStatus(err), err
}
@@ -63,11 +63,12 @@ var withHashFile = func(fn handleFunc) handleFunc {
filePath = ifPath
}
- // set fs root to the shared file/folder. ScopedFs (not a bare
- // BasePathFs) so the share is also symlink-confined: a link inside the
- // shared subtree that points elsewhere in the owner's scope — outside
- // the share — must not be followed.
- d.user.Fs = files.NewScopedFs(d.user.Fs, basePath)
+ // set fs root to the shared file/folder. Unless external symlinks are
+ // explicitly allowed, this is a ScopedFs (not a bare BasePathFs) so the
+ // share is also symlink-confined: a link inside the shared subtree that
+ // points elsewhere in the owner's scope — outside the share — must not be
+ // followed.
+ d.user.Fs = files.NewFs(d.user.Fs, basePath, d.server.FollowExternalSymlinks)
// the filesystem is now rebased onto basePath, so paths handed to the
// rule checker are relative to it. Resolve them back to the user's
diff --git a/http/public_symlink_test.go b/http/public_symlink_test.go
index fa631c82..0343046e 100644
--- a/http/public_symlink_test.go
+++ b/http/public_symlink_test.go
@@ -126,6 +126,86 @@ func TestPublicShareSymlinkListingOmitsEscapingLink(t *testing.T) {
}
}
+// With Server.FollowExternalSymlinks enabled (the opt-in for issue #5998), a
+// symlink inside a public share that points outside it is followed: the file
+// behind it downloads and the link shows up in the share listing. This is the
+// inverse of TestPublicShareSymlinkDescendantDisclosure / ...ListingOmitsEscapingLink.
+func TestPublicShareSymlinkFollowedWhenEnabled(t *testing.T) {
+ scope := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(scope, "shared"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(scope, "outside"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(scope, "outside", "data.txt"), []byte("payload"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(scope, "outside"), filepath.Join(scope, "shared", "link")); err != nil {
+ t.Skipf("cannot create symlink on this platform: %v", err)
+ }
+
+ db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
+ if err != nil {
+ t.Fatalf("failed to open db: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ st, err := bolt.NewStorage(db)
+ if err != nil {
+ t.Fatalf("failed to get storage: %v", err)
+ }
+ if err := st.Share.Save(&share.Link{Hash: "h", UserID: 1, Path: "/shared"}); err != nil {
+ t.Fatalf("failed to save share: %v", err)
+ }
+ if err := st.Users.Save(&users.User{
+ Username: "username",
+ Password: "pw",
+ Perm: users.Permissions{Share: true, Download: true},
+ }); err != nil {
+ t.Fatalf("failed to save user: %v", err)
+ }
+ if err := st.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil {
+ t.Fatalf("failed to save settings: %v", err)
+ }
+ // Follow-external mode: the user filesystem is a bare BasePathFs.
+ st.Users = &customFSUser{
+ Store: st.Users,
+ fs: files.NewFs(afero.NewOsFs(), scope, true),
+ followExternal: true,
+ }
+
+ srv := &settings.Server{FollowExternalSymlinks: true}
+
+ // The file behind the symlink downloads.
+ req := newHTTPRequest(t, func(r *http.Request) { r.URL.Path = "h/link/data.txt" })
+ recorder := httptest.NewRecorder()
+ handle(publicDlHandler, "", st, srv).ServeHTTP(recorder, req)
+ result := recorder.Result()
+ defer result.Body.Close()
+ body, _ := io.ReadAll(result.Body)
+ if result.StatusCode != http.StatusOK {
+ t.Fatalf("expected to download file behind followed symlink, got status=%d body=%q", result.StatusCode, string(body))
+ }
+ if !strings.Contains(string(body), "payload") {
+ t.Fatalf("expected payload content, got %q", string(body))
+ }
+
+ // The link shows up in the share root listing.
+ req = newHTTPRequest(t, func(r *http.Request) { r.URL.Path = "h/" })
+ recorder = httptest.NewRecorder()
+ handle(publicShareHandler, "", st, srv).ServeHTTP(recorder, req)
+ result = recorder.Result()
+ defer result.Body.Close()
+ body, _ = io.ReadAll(result.Body)
+ if result.StatusCode != http.StatusOK {
+ t.Fatalf("share root listing failed: status=%d body=%q", result.StatusCode, string(body))
+ }
+ if !strings.Contains(string(body), "\"link\"") {
+ t.Fatalf("expected followed symlink to appear in listing: %s", string(body))
+ }
+}
+
// Reproduces the archive variant of GHSA-hf77-9m7w-fq8q: downloading the whole
// public share as a zip must not pull in files reached through a symlinked
// descendant.
diff --git a/http/public_test.go b/http/public_test.go
index 0b0b7677..c728102d 100644
--- a/http/public_test.go
+++ b/http/public_test.go
@@ -269,16 +269,24 @@ func newHTTPRequest(t *testing.T, requestModifiers ...func(*http.Request)) *http
type customFSUser struct {
users.Store
fs afero.Fs
+ // followExternal mirrors Server.FollowExternalSymlinks: when set, the
+ // provided fs is used as-is (a bare BasePathFs that follows symlinks);
+ // otherwise it is wrapped in a symlink-confining ScopedFs.
+ followExternal bool
}
-func (cu *customFSUser) Get(baseScope string, id interface{}) (*users.User, error) {
- user, err := cu.Store.Get(baseScope, id)
+func (cu *customFSUser) Get(baseScope string, followExternalSymlinks bool, id interface{}) (*users.User, error) {
+ user, err := cu.Store.Get(baseScope, followExternalSymlinks, id)
if err != nil {
return nil, err
}
- // Mirror production (users.User init), where a user's filesystem is always a
- // scoped, symlink-confining ScopedFs rather than a bare afero.Fs.
- user.Fs = files.NewScopedFs(cu.fs, "/")
+ // Inject a filesystem rooted at the test's temp scope, standing in for the
+ // one users.User.Clean would build in production.
+ if cu.followExternal {
+ user.Fs = cu.fs
+ } else {
+ user.Fs = files.NewScopedFs(cu.fs, "/")
+ }
return user, nil
}
diff --git a/http/raw.go b/http/raw.go
index 860d6aa7..8f8c5751 100644
--- a/http/raw.go
+++ b/http/raw.go
@@ -2,6 +2,7 @@ package fbhttp
import (
"errors"
+ "fmt"
"io/fs"
"log"
"net/http"
@@ -125,12 +126,19 @@ func getFiles(d *data, path, commonPath string) ([]archives.FileInfo, error) {
nameInArchive := strings.TrimPrefix(path, commonPath)
nameInArchive = strings.TrimPrefix(nameInArchive, string(filepath.Separator))
nameInArchive = filepath.ToSlash(nameInArchive)
- // filepath.ToSlash only rewrites the host separator, so on a Linux
- // host a stored backslash survives and is emitted verbatim into the
- // archive. Windows extractors then treat "\" as a path separator,
- // allowing the entry to escape the extraction directory (zip-slip).
- // Strip Windows separators regardless of host OS.
- nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "/")
+ // A backslash is a legal filename character on POSIX hosts, so it can
+ // reach here verbatim. Rewriting it to the path separator "/" would
+ // manufacture a traversal sequence (e.g. "..\..\x" -> "../../x") that
+ // escapes the extraction directory on the victim's machine, while
+ // leaving it as "\" lets Windows extractors treat it as a separator.
+ // Neutralize it to an inert character instead of turning it into one.
+ nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "_")
+
+ // Defense in depth: never emit an archive entry whose path escapes the
+ // archive root, regardless of how the name was produced.
+ if cleaned := gopath.Clean("/" + nameInArchive); cleaned != "/"+nameInArchive {
+ return nil, fmt.Errorf("refusing unsafe archive entry name: %q", nameInArchive)
+ }
archiveFiles = append(archiveFiles, archives.FileInfo{
FileInfo: info,
diff --git a/http/raw_test.go b/http/raw_test.go
index c35334f6..7fe3e916 100644
--- a/http/raw_test.go
+++ b/http/raw_test.go
@@ -1,14 +1,75 @@
package fbhttp
import (
+ "archive/zip"
+ "bytes"
"net/http"
"net/http/httptest"
"net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
"testing"
"github.com/filebrowser/filebrowser/v2/files"
+ "github.com/filebrowser/filebrowser/v2/settings"
+ "github.com/filebrowser/filebrowser/v2/users"
)
+// Regression for the archive backslash-to-slash zip-slip (GHSA-83xp-526h-j3ww):
+// a single in-scope file whose name contains backslashes is a legal POSIX
+// filename, not a traversal. The archive builder must never rewrite "\" into the
+// path separator "/", which would manufacture an entry like "../../evil.sh" that
+// escapes the extraction directory on the downloader's machine.
+func TestRawArchiveDoesNotManufactureTraversal(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ if err := os.MkdirAll(filepath.Join(userScope, "ziptest"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // One legal Linux/macOS filename whose bytes include backslashes. It does not
+ // traverse on the server; it only becomes "../../evil.sh" if the builder
+ // turns "\" into "/".
+ planted := filepath.Join(userScope, "ziptest", "..\\..\\evil.sh")
+ if err := os.WriteFile(planted, []byte("#!/bin/sh\necho PWNED"), 0o644); err != nil {
+ t.Skipf("cannot create backslash-named file: %v", err)
+ }
+
+ key := []byte("test-signing-key")
+ perm := users.Permissions{Download: true}
+ st := scopedUserStorage(t, userScope, perm, key)
+ signed := signToken(t, perm, key)
+
+ req, _ := http.NewRequest(http.MethodGet, "/ziptest?algo=zip", http.NoBody)
+ req.Header.Set("X-Auth", signed)
+ rec := httptest.NewRecorder()
+ handle(rawHandler, "", st, &settings.Server{}).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d body=%q", rec.Code, rec.Body.String())
+ }
+
+ zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
+ if err != nil {
+ t.Fatalf("failed to read zip: %v", err)
+ }
+ if len(zr.File) == 0 {
+ t.Fatal("archive has no entries")
+ }
+
+ for _, f := range zr.File {
+ // The entry must be a normalized, root-relative path: no ".." segments
+ // and no leading "/". Note a name may legitimately contain ".." as part of
+ // a single filename (e.g. ".._.._evil.sh"), which Clean leaves untouched —
+ // so compare against the normalized form rather than searching for "..".
+ if strings.HasPrefix(f.Name, "/") || path.Clean("/"+f.Name) != "/"+f.Name {
+ t.Errorf("VULNERABLE: archive entry escapes root: %q", f.Name)
+ }
+ }
+}
+
func TestSetContentDisposition(t *testing.T) {
t.Parallel()
diff --git a/http/resource.go b/http/resource.go
index 728bad73..d8f5cad9 100644
--- a/http/resource.go
+++ b/http/resource.go
@@ -130,7 +130,9 @@ func resourcePostHandler(fileCache FileCache) handleFunc {
// Directories creation on POST.
if strings.HasSuffix(r.URL.Path, "/") {
- err := d.user.Fs.MkdirAll(r.URL.Path, d.settings.DirMode)
+ err := d.RunHook(func() error {
+ return d.user.Fs.MkdirAll(r.URL.Path, d.settings.DirMode)
+ }, "upload", r.URL.Path, "", d.user)
return errToStatus(err), err
}
@@ -424,7 +426,10 @@ var resourceGetRecursiveHandler = withUser(func(w http.ResponseWriter, r *http.R
}
entries = append(entries, RecursiveEntry{
- Path: fPath,
+ // afero.Walk joins paths with the OS separator, so on Windows fPath
+ // uses backslashes. The web API contract is forward slashes, so
+ // normalize it here (mirrors search/search.go).
+ Path: filepath.ToSlash(fPath),
Name: info.Name(),
Size: info.Size(),
ModTime: info.ModTime(),
diff --git a/http/resource_test.go b/http/resource_test.go
index 7081206f..5a29518d 100644
--- a/http/resource_test.go
+++ b/http/resource_test.go
@@ -5,6 +5,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -14,6 +15,7 @@ import (
"github.com/filebrowser/filebrowser/v2/diskcache"
"github.com/filebrowser/filebrowser/v2/settings"
+ "github.com/filebrowser/filebrowser/v2/storage"
"github.com/filebrowser/filebrowser/v2/storage/bolt"
"github.com/filebrowser/filebrowser/v2/users"
)
@@ -115,3 +117,143 @@ func signToken(t *testing.T, perm users.Permissions, key []byte) string {
}
return signed
}
+
+// scopedUserStorage returns a storage whose single user (ID 1) is scoped to
+// userScope through a symlink-confining ScopedFs (via customFSUser), mirroring
+// production. Used by the symlink scope-escape regression tests below.
+func scopedUserStorage(t *testing.T, userScope string, perm users.Permissions, key []byte) *storage.Storage {
+ t.Helper()
+ db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
+ if err != nil {
+ t.Fatalf("failed to open db: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ st, err := bolt.NewStorage(db)
+ if err != nil {
+ t.Fatalf("failed to get storage: %v", err)
+ }
+ if err := st.Users.Save(&users.User{Username: "u", Password: "pw", Perm: perm}); err != nil {
+ t.Fatalf("failed to save user: %v", err)
+ }
+ if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil {
+ t.Fatalf("failed to save settings: %v", err)
+ }
+ st.Users = &customFSUser{
+ Store: st.Users,
+ fs: afero.NewBasePathFs(afero.NewOsFs(), userScope),
+ }
+ return st
+}
+
+// Regression for the dangling-symlink write escape (GHSA-8wc8-hf36-mjh9 /
+// GHSA-fh54-6rfh-r8f3): POSTing to an in-scope dangling symlink whose target is
+// outside the scope must not dereference the link to create the out-of-scope
+// file.
+func TestResourcePostRejectsDanglingSymlinkWriteEscape(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ outside := filepath.Join(root, "outside")
+ for _, d := range []string{userScope, outside} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // A dangling symlink inside the scope pointing at a not-yet-existing file
+ // outside it (planted out-of-band, per the advisory preconditions).
+ outsideTarget := filepath.Join(outside, "created.txt")
+ if err := os.Symlink(outsideTarget, filepath.Join(userScope, "evil")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+
+ key := []byte("test-signing-key")
+ perm := users.Permissions{Create: true, Modify: true}
+ st := scopedUserStorage(t, userScope, perm, key)
+ signed := signToken(t, perm, key)
+
+ req, _ := http.NewRequest(http.MethodPost, "/evil?override=true", strings.NewReader("http-outside"))
+ req.Header.Set("X-Auth", signed)
+ rec := httptest.NewRecorder()
+ handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)
+
+ if _, statErr := os.Stat(outsideTarget); statErr == nil {
+ data, _ := os.ReadFile(outsideTarget)
+ t.Fatalf("VULNERABLE: out-of-scope file created via dangling symlink (status=%d, content=%q)", rec.Code, string(data))
+ }
+ if rec.Code != http.StatusForbidden {
+ t.Errorf("expected 403, got %d body=%q", rec.Code, rec.Body.String())
+ }
+}
+
+// Regression for the symlink-following delete escape (GHSA-hq4g-mpch-f9vp /
+// GHSA-fmm7-x4gx-8jhr): a Create-only user POSTing to a child of an escaping
+// symlinked directory must not delete the out-of-scope target through the
+// failed-upload cleanup RemoveAll.
+func TestResourcePostCleanupDoesNotDeleteThroughSymlink(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ outside := filepath.Join(root, "outside")
+ for _, d := range []string{userScope, outside} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ victim := filepath.Join(outside, "victim.txt")
+ if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // An escaping directory symlink inside the scope (planted out-of-band).
+ if err := os.Symlink(outside, filepath.Join(userScope, "link")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+
+ key := []byte("test-signing-key")
+ // Create-only: Perm.Delete is deliberately false — the bug must not need it.
+ perm := users.Permissions{Create: true}
+ st := scopedUserStorage(t, userScope, perm, key)
+ signed := signToken(t, perm, key)
+
+ req, _ := http.NewRequest(http.MethodPost, "/link/victim.txt", strings.NewReader("x"))
+ req.Header.Set("X-Auth", signed)
+ rec := httptest.NewRecorder()
+ handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)
+
+ if _, statErr := os.Stat(victim); statErr != nil {
+ t.Fatalf("VULNERABLE: out-of-scope victim.txt deleted by cleanup RemoveAll (status=%d): %v", rec.Code, statErr)
+ }
+}
+
+func TestResourcePostRunsUploadHooksForDirectories(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ if err := os.MkdirAll(userScope, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ key := []byte("test-signing-key")
+ perm := users.Permissions{Create: true}
+ st := scopedUserStorage(t, userScope, perm, key)
+ if err := st.Settings.Save(&settings.Settings{
+ Key: key,
+ Commands: map[string][]string{
+ "after_upload": {"filebrowser-hook-command-that-does-not-exist"},
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ req, _ := http.NewRequest(http.MethodPost, "/created/", http.NoBody)
+ req.Header.Set("X-Auth", signToken(t, perm, key))
+ rec := httptest.NewRecorder()
+ handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{EnableExec: true}).ServeHTTP(rec, req)
+
+ // A missing after_upload command makes the request fail only if the hook ran.
+ // It avoids a platform-specific helper executable while still exercising the
+ // same path the web UI uses for directory uploads.
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("expected directory upload hook failure to return 500, got %d body=%q", rec.Code, rec.Body.String())
+ }
+ if _, err := os.Stat(filepath.Join(userScope, "created")); err != nil {
+ t.Fatalf("expected directory to be created before its after hook, got %v", err)
+ }
+}
diff --git a/http/share.go b/http/share.go
index 7cd08faf..8ed4feb4 100644
--- a/http/share.go
+++ b/http/share.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net/http"
+ "path/filepath"
"sort"
"strconv"
"strings"
@@ -14,9 +15,40 @@ import (
fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/share"
+ "github.com/filebrowser/filebrowser/v2/users"
"golang.org/x/crypto/bcrypt"
)
+// shareResponse is the client-facing representation of a share. It deliberately
+// omits the server-side secrets of share.Link — the bcrypt PasswordHash (which
+// would be crackable offline) and the bypass Token — exposing only whether the
+// share is password-protected via HasPassword.
+type shareResponse struct {
+ Hash string `json:"hash"`
+ Path string `json:"path"`
+ UserID uint `json:"userID"`
+ Expire int64 `json:"expire"`
+ HasPassword bool `json:"hasPassword"`
+}
+
+func toShareResponse(l *share.Link) *shareResponse {
+ return &shareResponse{
+ Hash: l.Hash,
+ Path: l.Path,
+ UserID: l.UserID,
+ Expire: l.Expire,
+ HasPassword: l.PasswordHash != "",
+ }
+}
+
+func toShareResponses(links []*share.Link) []*shareResponse {
+ res := make([]*shareResponse, 0, len(links))
+ for _, l := range links {
+ res = append(res, toShareResponse(l))
+ }
+ return res
+}
+
func withPermShare(fn handleFunc) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Share || !d.user.Perm.Download {
@@ -38,7 +70,7 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
s, err = d.store.Share.FindByUserID(d.user.ID)
}
if errors.Is(err, fberrors.ErrNotExist) {
- return renderJSON(w, r, []*share.Link{})
+ return renderJSON(w, r, []*shareResponse{})
}
if err != nil {
@@ -52,7 +84,7 @@ var shareListHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
return s[i].Expire < s[j].Expire
})
- return renderJSON(w, r, s)
+ return renderJSON(w, r, toShareResponses(s))
})
var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
@@ -61,21 +93,47 @@ var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
err error
)
if d.user.Perm.Admin {
- s, err = d.store.Share.GetsByPath(r.URL.Path)
+ s, err = getSharesForAdminPath(d, r.URL.Path)
} else {
s, err = d.store.Share.Gets(r.URL.Path, d.user.ID)
}
if errors.Is(err, fberrors.ErrNotExist) {
- return renderJSON(w, r, []*share.Link{})
+ return renderJSON(w, r, []*shareResponse{})
}
if err != nil {
return http.StatusInternalServerError, err
}
- return renderJSON(w, r, s)
+ return renderJSON(w, r, toShareResponses(s))
})
+func getSharesForAdminPath(d *data, path string) ([]*share.Link, error) {
+ links, err := d.store.Share.All()
+ if err != nil {
+ return nil, err
+ }
+
+ adminPath := filepath.Clean(d.user.FullPath(path))
+ owners := make(map[uint]*users.User)
+ filtered := make([]*share.Link, 0, len(links))
+ for _, link := range links {
+ owner, ok := owners[link.UserID]
+ if !ok {
+ owner, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, link.UserID)
+ if err != nil && !errors.Is(err, fberrors.ErrNotExist) {
+ return nil, err
+ }
+ owners[link.UserID] = owner // owner is nil on ErrNotExist
+ }
+ if owner != nil && filepath.Clean(owner.FullPath(link.Path)) == adminPath {
+ filtered = append(filtered, link)
+ }
+ }
+
+ return filtered, nil
+}
+
var shareDeleteHandler = withPermShare(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
hash := strings.TrimSuffix(r.URL.Path, "/")
hash = strings.TrimPrefix(hash, "/")
@@ -176,7 +234,7 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
return http.StatusInternalServerError, err
}
- return renderJSON(w, r, s)
+ return renderJSON(w, r, toShareResponse(s))
})
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
diff --git a/http/share_test.go b/http/share_test.go
new file mode 100644
index 00000000..c03a9393
--- /dev/null
+++ b/http/share_test.go
@@ -0,0 +1,164 @@
+package fbhttp
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/asdine/storm/v3"
+ "github.com/golang-jwt/jwt/v5"
+
+ "github.com/filebrowser/filebrowser/v2/settings"
+ "github.com/filebrowser/filebrowser/v2/share"
+ "github.com/filebrowser/filebrowser/v2/storage/bolt"
+ "github.com/filebrowser/filebrowser/v2/users"
+)
+
+func TestAdminShareGetsHandlerMatchesOwnerScope(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ ownerScope := filepath.Join(root, "owner")
+ if err := os.MkdirAll(ownerScope, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ownerScope, "file.txt"), []byte("shared"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
+ if err != nil {
+ t.Fatalf("failed to open db: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ st, err := bolt.NewStorage(db)
+ if err != nil {
+ t.Fatalf("failed to get storage: %v", err)
+ }
+
+ owner := &users.User{
+ Username: "owner",
+ Password: "pw",
+ Scope: "/owner",
+ Perm: users.Permissions{Share: true, Download: true},
+ }
+ if err := st.Users.Save(owner); err != nil {
+ t.Fatalf("failed to save owner: %v", err)
+ }
+
+ adminPerm := users.Permissions{Admin: true, Share: true, Download: true}
+ admin := &users.User{
+ Username: "admin",
+ Password: "pw",
+ Scope: "/",
+ Perm: adminPerm,
+ }
+ if err := st.Users.Save(admin); err != nil {
+ t.Fatalf("failed to save admin: %v", err)
+ }
+
+ if err := st.Share.Save(&share.Link{Hash: "h", UserID: owner.ID, Path: "/file.txt"}); err != nil {
+ t.Fatalf("failed to save share: %v", err)
+ }
+ key := []byte("test-signing-key")
+ if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil {
+ t.Fatalf("failed to save settings: %v", err)
+ }
+
+ req, err := http.NewRequest(http.MethodGet, "/owner/file.txt", http.NoBody)
+ if err != nil {
+ t.Fatalf("failed to construct request: %v", err)
+ }
+ req.Header.Set("X-Auth", signShareTestToken(t, admin.ID, admin.Username, adminPerm, key))
+
+ rec := httptest.NewRecorder()
+ handle(shareGetsHandler, "", st, &settings.Server{Root: root}).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var links []*share.Link
+ if err := json.Unmarshal(rec.Body.Bytes(), &links); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if len(links) != 1 || links[0].Hash != "h" {
+ t.Fatalf("expected admin to see owner share h, got %#v", links)
+ }
+}
+
+// Regression for the share secret exposure (GHSA-833g-cqhp-h72j): the share API
+// must not serialize the bcrypt password hash or the bypass token, while still
+// persisting them server-side so password-protected shares keep working.
+func TestSharePostHandlerDoesNotLeakSecrets(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ if err := os.MkdirAll(userScope, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(userScope, "file.txt"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ key := []byte("test-signing-key")
+ perm := users.Permissions{Share: true, Download: true}
+ st := scopedUserStorage(t, userScope, perm, key)
+ signed := signToken(t, perm, key)
+
+ body := `{"password":"ShareSecret123!","expires":"24","unit":"hours"}`
+ req, _ := http.NewRequest(http.MethodPost, "/file.txt", strings.NewReader(body))
+ req.Header.Set("X-Auth", signed)
+ rec := httptest.NewRecorder()
+ handle(sharePostHandler, "", st, &settings.Server{Root: root}).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d body=%q", rec.Code, rec.Body.String())
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if _, ok := resp["password_hash"]; ok {
+ t.Errorf("VULNERABLE: response leaks password_hash: %s", rec.Body.String())
+ }
+ if _, ok := resp["token"]; ok {
+ t.Errorf("VULNERABLE: response leaks token: %s", rec.Body.String())
+ }
+ if resp["hasPassword"] != true {
+ t.Errorf("expected hasPassword=true, got %v", resp["hasPassword"])
+ }
+
+ // The secrets must still be persisted server-side (storm uses the JSON codec,
+ // so the storage struct's tags must keep emitting them).
+ stored, err := st.Share.GetByHash(resp["hash"].(string))
+ if err != nil {
+ t.Fatalf("share not stored: %v", err)
+ }
+ if stored.PasswordHash == "" || stored.Token == "" {
+ t.Fatalf("server-side secrets not persisted: hash=%q token=%q", stored.PasswordHash, stored.Token)
+ }
+}
+
+func signShareTestToken(t *testing.T, id uint, username string, perm users.Permissions, key []byte) string {
+ t.Helper()
+
+ claims := &authToken{
+ User: userInfo{ID: id, Username: username, Perm: perm},
+ RegisteredClaims: jwt.RegisteredClaims{
+ IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ },
+ }
+ signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key)
+ if err != nil {
+ t.Fatalf("failed to sign token: %v", err)
+ }
+ return signed
+}
diff --git a/http/subtitle.go b/http/subtitle.go
index 07c2dcfc..24b49256 100644
--- a/http/subtitle.go
+++ b/http/subtitle.go
@@ -2,7 +2,9 @@ package fbhttp
import (
"bytes"
+ "io"
"net/http"
+ "regexp"
"strings"
"github.com/asticode/go-astisub"
@@ -10,6 +12,8 @@ import (
"github.com/filebrowser/filebrowser/v2/files"
)
+var srtLineBreakTag = regexp.MustCompile(`(?i)
]*)?\s*/?>`)
+
var subtitleHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
@@ -49,7 +53,11 @@ func subtitleFileHandler(w http.ResponseWriter, r *http.Request, file *files.Fil
// load subtitle for conversion to vtt
var sub *astisub.Subtitles
if strings.HasSuffix(file.Name, ".srt") {
- sub, err = astisub.ReadFromSRT(fd)
+ content, readErr := io.ReadAll(fd)
+ if readErr != nil {
+ return http.StatusInternalServerError, readErr
+ }
+ sub, err = astisub.ReadFromSRT(bytes.NewReader(normalizeSRTLineBreaks(content)))
} else if strings.HasSuffix(file.Name, ".ass") || strings.HasSuffix(file.Name, ".ssa") {
sub, err = astisub.ReadFromSSA(fd)
}
@@ -78,3 +86,7 @@ func subtitleFileHandler(w http.ResponseWriter, r *http.Request, file *files.Fil
http.ServeContent(w, r, file.Name, file.ModTime, bytes.NewReader(buf.Bytes()))
return 0, nil
}
+
+func normalizeSRTLineBreaks(content []byte) []byte {
+ return srtLineBreakTag.ReplaceAll(content, []byte("\n"))
+}
diff --git a/http/subtitle_test.go b/http/subtitle_test.go
new file mode 100644
index 00000000..2520d453
--- /dev/null
+++ b/http/subtitle_test.go
@@ -0,0 +1,62 @@
+package fbhttp
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/spf13/afero"
+
+ "github.com/filebrowser/filebrowser/v2/files"
+)
+
+func TestNormalizeSRTLineBreaks(t *testing.T) {
+ input := []byte("first
second
third
fourth
fifth")
+ got := string(normalizeSRTLineBreaks(input))
+ want := "first\nsecond\nthird\nfourth\nfifth"
+ if got != want {
+ t.Fatalf("normalizeSRTLineBreaks() = %q, want %q", got, want)
+ }
+}
+
+func TestSubtitleFileHandlerConvertsSRTBreakTags(t *testing.T) {
+ fs := afero.NewMemMapFs()
+ const path = "/sample.srt"
+ const content = "1\n" +
+ "00:00:01,000 --> 00:00:02,000\n" +
+ "First
Second
Third
Fourth\n\n"
+
+ if err := afero.WriteFile(fs, path, []byte(content), 0o644); err != nil {
+ t.Fatalf("failed to write subtitle: %v", err)
+ }
+ info, err := fs.Stat(path)
+ if err != nil {
+ t.Fatalf("failed to stat subtitle: %v", err)
+ }
+
+ file := &files.FileInfo{
+ Fs: fs,
+ Path: path,
+ Name: "sample.srt",
+ ModTime: info.ModTime(),
+ }
+ req := httptest.NewRequest(http.MethodGet, "/api/subtitle/sample.srt?inline=true", http.NoBody)
+ rec := httptest.NewRecorder()
+
+ status, err := subtitleFileHandler(rec, req, file)
+ if err != nil {
+ t.Fatalf("subtitleFileHandler returned error: %v", err)
+ }
+ if status != 0 {
+ t.Fatalf("subtitleFileHandler status = %d, want 0", status)
+ }
+
+ body := rec.Body.String()
+ if strings.Contains(body, "FirstSecond") {
+ t.Fatalf("WebVTT output collapsed SRT
tags: %q", body)
+ }
+ if !strings.Contains(body, "First\nSecond\nThird\nFourth") {
+ t.Fatalf("WebVTT output = %q, want converted SRT
tags as line breaks", body)
+ }
+}
diff --git a/http/users.go b/http/users.go
index e61ab00b..13092e3e 100644
--- a/http/users.go
+++ b/http/users.go
@@ -71,7 +71,7 @@ func withSelfOrAdmin(fn handleFunc) handleFunc {
}
var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
- users, err := d.store.Users.Gets(d.server.Root)
+ users, err := d.store.Users.Gets(d.server.Root, d.server.FollowExternalSymlinks)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -88,7 +88,7 @@ var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *
})
var userGetHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
- u, err := d.store.Users.Get(d.server.Root, d.raw.(uint))
+ u, err := d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, d.raw.(uint))
if errors.Is(err, fberrors.ErrNotExist) {
return http.StatusNotFound, err
}
@@ -228,7 +228,7 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
}
} else {
var suser *users.User
- suser, err = d.store.Users.Get(d.server.Root, d.raw.(uint))
+ suser, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, d.raw.(uint))
if err != nil {
return http.StatusInternalServerError, err
}
diff --git a/settings/settings.go b/settings/settings.go
index d71be16a..44297fd5 100644
--- a/settings/settings.go
+++ b/settings/settings.go
@@ -47,21 +47,22 @@ func (s *Settings) GetRules() []rules.Rule {
// Server specific settings.
type Server struct {
- Root string `json:"root"`
- BaseURL string `json:"baseURL"`
- Socket string `json:"socket"`
- TLSKey string `json:"tlsKey"`
- TLSCert string `json:"tlsCert"`
- Port string `json:"port"`
- Address string `json:"address"`
- Log string `json:"log"`
- EnableThumbnails bool `json:"enableThumbnails"`
- ResizePreview bool `json:"resizePreview"`
- EnableExec bool `json:"enableExec"`
- TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
- ImageResolutionCal bool `json:"imageResolutionCalculation"`
- AuthHook string `json:"authHook"`
- TokenExpirationTime string `json:"tokenExpirationTime"`
+ Root string `json:"root"`
+ BaseURL string `json:"baseURL"`
+ Socket string `json:"socket"`
+ TLSKey string `json:"tlsKey"`
+ TLSCert string `json:"tlsCert"`
+ Port string `json:"port"`
+ Address string `json:"address"`
+ Log string `json:"log"`
+ EnableThumbnails bool `json:"enableThumbnails"`
+ ResizePreview bool `json:"resizePreview"`
+ EnableExec bool `json:"enableExec"`
+ TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
+ ImageResolutionCal bool `json:"imageResolutionCalculation"`
+ AuthHook string `json:"authHook"`
+ TokenExpirationTime string `json:"tokenExpirationTime"`
+ FollowExternalSymlinks bool `json:"followExternalSymlinks"`
}
// Clean cleans any variables that might need cleaning.
diff --git a/share/storage.go b/share/storage.go
index 684ab018..3b73ef35 100644
--- a/share/storage.go
+++ b/share/storage.go
@@ -110,23 +110,6 @@ func (s *Storage) Gets(path string, id uint) ([]*Link, error) {
return links, nil
}
-// GetsByPath returns all non-expired links for a path.
-func (s *Storage) GetsByPath(path string) ([]*Link, error) {
- links, err := s.All()
- if err != nil {
- return nil, err
- }
-
- filtered := make([]*Link, 0, len(links))
- for _, link := range links {
- if link.Path == path {
- filtered = append(filtered, link)
- }
- }
-
- return filtered, nil
-}
-
// Save wraps a StorageBackend.Save
func (s *Storage) Save(l *Link) error {
return s.back.Save(l)
diff --git a/share/storage_test.go b/share/storage_test.go
deleted file mode 100644
index dd78fe71..00000000
--- a/share/storage_test.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package share
-
-import (
- "reflect"
- "testing"
-)
-
-type fakeBackend struct {
- links []*Link
-}
-
-func (f fakeBackend) All() ([]*Link, error) {
- return f.links, nil
-}
-
-func (f fakeBackend) FindByUserID(id uint) ([]*Link, error) {
- var links []*Link
- for _, link := range f.links {
- if link.UserID == id {
- links = append(links, link)
- }
- }
- return links, nil
-}
-
-func (f fakeBackend) GetByHash(hash string) (*Link, error) {
- for _, link := range f.links {
- if link.Hash == hash {
- return link, nil
- }
- }
- return nil, nil
-}
-
-func (f fakeBackend) GetPermanent(path string, id uint) (*Link, error) {
- for _, link := range f.links {
- if link.Path == path && link.UserID == id && link.Expire == 0 {
- return link, nil
- }
- }
- return nil, nil
-}
-
-func (f fakeBackend) Gets(path string, id uint) ([]*Link, error) {
- var links []*Link
- for _, link := range f.links {
- if link.Path == path && link.UserID == id {
- links = append(links, link)
- }
- }
- return links, nil
-}
-
-func (f fakeBackend) Save(_ *Link) error {
- return nil
-}
-
-func (f fakeBackend) Delete(_ string) error {
- return nil
-}
-
-func (f fakeBackend) DeleteWithPathPrefix(_ string, _ uint) error {
- return nil
-}
-
-func TestGetsByPathReturnsLinksFromAllUsers(t *testing.T) {
- t.Parallel()
-
- expected := []*Link{
- {Hash: "a", Path: "/file.txt", UserID: 1},
- {Hash: "b", Path: "/file.txt", UserID: 2},
- }
- store := NewStorage(fakeBackend{
- links: append(expected, &Link{Hash: "c", Path: "/other.txt", UserID: 3}),
- })
-
- links, err := store.GetsByPath("/file.txt")
- if err != nil {
- t.Fatalf("GetsByPath returned error: %v", err)
- }
-
- if !reflect.DeepEqual(links, expected) {
- t.Fatalf("GetsByPath returned %#v, want %#v", links, expected)
- }
-}
diff --git a/storage/bolt/share.go b/storage/bolt/share.go
index 621ea6f1..49b3f0c4 100644
--- a/storage/bolt/share.go
+++ b/storage/bolt/share.go
@@ -78,16 +78,17 @@ func (s shareBackend) Delete(hash string) error {
}
func (s shareBackend) DeleteWithPathPrefix(pathPrefix string, userID uint) error {
+ // Share paths are stored without a trailing slash
+ prefix := strings.TrimRight(pathPrefix, "/")
+
var links []share.Link
- if err := s.db.Prefix("Path", pathPrefix, &links); err != nil {
+ if err := s.db.Prefix("Path", prefix, &links); err != nil {
if errors.Is(err, storm.ErrNotFound) {
return nil
}
return err
}
- prefix := strings.TrimRight(pathPrefix, "/")
-
var err error
for _, link := range links {
if link.UserID != userID {
diff --git a/storage/bolt/share_test.go b/storage/bolt/share_test.go
index e303cec6..9f96a847 100644
--- a/storage/bolt/share_test.go
+++ b/storage/bolt/share_test.go
@@ -84,6 +84,44 @@ func TestDeleteWithPathPrefix(t *testing.T) {
}
}
+// Regression for the trailing-slash delete leaving a stale share
+// (GHSA-pp88-jhwj-5qh5): deleting "/a/" must remove the exact "/a" share and its
+// descendants, not just the descendants. Siblings and other users are untouched.
+func TestDeleteWithPathPrefixTrailingSlash(t *testing.T) {
+ t.Parallel()
+
+ s := newTestShareBackend(t)
+
+ links := []*share.Link{
+ {Hash: "u1-a", Path: "/a", UserID: 1},
+ {Hash: "u1-a-child", Path: "/a/child.txt", UserID: 1},
+ {Hash: "u1-abc", Path: "/abc", UserID: 1}, // sibling sharing a byte prefix
+ {Hash: "u2-a", Path: "/a", UserID: 2}, // other user, must remain
+ }
+ for _, l := range links {
+ if err := s.Save(l); err != nil {
+ t.Fatalf("failed to save link %s: %v", l.Hash, err)
+ }
+ }
+
+ // Delete with a trailing slash, as the resource delete handler does for a
+ // directory request like DELETE /api/resources/a/.
+ if err := s.DeleteWithPathPrefix("/a/", 1); err != nil {
+ t.Fatalf("DeleteWithPathPrefix returned error: %v", err)
+ }
+
+ got := remainingHashes(t, s)
+ want := []string{"u1-abc", "u2-a"}
+ if len(got) != len(want) {
+ t.Fatalf("remaining hashes = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("remaining hashes = %v, want %v", got, want)
+ }
+ }
+}
+
func TestDeleteWithPathPrefixNoMatch(t *testing.T) {
t.Parallel()
diff --git a/storage/bolt/users.go b/storage/bolt/users.go
index 6686d941..33f67abb 100644
--- a/storage/bolt/users.go
+++ b/storage/bolt/users.go
@@ -6,6 +6,7 @@ import (
"reflect"
"github.com/asdine/storm/v3"
+ "github.com/asdine/storm/v3/q"
bolt "go.etcd.io/bbolt"
fberrors "github.com/filebrowser/filebrowser/v2/errors"
@@ -41,6 +42,19 @@ func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
return
}
+func (st usersBackend) GetByScope(scope string) (*users.User, error) {
+ user := &users.User{}
+ err := st.db.Select(q.Eq("Scope", scope)).First(user)
+ if err != nil {
+ if errors.Is(err, storm.ErrNotFound) {
+ return nil, fberrors.ErrNotExist
+ }
+ return nil, err
+ }
+
+ return user, nil
+}
+
func (st usersBackend) Gets() ([]*users.User, error) {
var allUsers []*users.User
err := st.db.All(&allUsers)
diff --git a/users/storage.go b/users/storage.go
index 33cfc9c4..ff10aeab 100644
--- a/users/storage.go
+++ b/users/storage.go
@@ -10,6 +10,7 @@ import (
// StorageBackend is the interface to implement for a users storage.
type StorageBackend interface {
GetBy(interface{}) (*User, error)
+ GetByScope(scope string) (*User, error)
Gets() ([]*User, error)
Save(u *User) error
Update(u *User, fields ...string) error
@@ -19,8 +20,9 @@ type StorageBackend interface {
}
type Store interface {
- Get(baseScope string, id interface{}) (user *User, err error)
- Gets(baseScope string) ([]*User, error)
+ Get(baseScope string, followExternalSymlinks bool, id interface{}) (user *User, err error)
+ GetByScope(scope string) (*User, error)
+ Gets(baseScope string, followExternalSymlinks bool) ([]*User, error)
Update(user *User, fields ...string) error
Save(user *User) error
Delete(id interface{}) error
@@ -45,26 +47,33 @@ func NewStorage(back StorageBackend) *Storage {
// Get allows you to get a user by its name or username. The provided
// id must be a string for username lookup or a uint for id lookup. If id
// is neither, a ErrInvalidDataType will be returned.
-func (s *Storage) Get(baseScope string, id interface{}) (user *User, err error) {
+func (s *Storage) Get(baseScope string, followExternalSymlinks bool, id interface{}) (user *User, err error) {
user, err = s.back.GetBy(id)
if err != nil {
return
}
- if err := user.Clean(baseScope); err != nil {
+ if err := user.Clean(baseScope, followExternalSymlinks); err != nil {
return nil, err
}
return
}
+// GetByScope returns the first user whose scope matches the given one, or
+// ErrNotExist if none does. The user is returned as stored, without setting up
+// its filesystem, as it is meant for existence checks rather than serving.
+func (s *Storage) GetByScope(scope string) (*User, error) {
+ return s.back.GetByScope(scope)
+}
+
// Gets gets a list of all users.
-func (s *Storage) Gets(baseScope string) ([]*User, error) {
+func (s *Storage) Gets(baseScope string, followExternalSymlinks bool) ([]*User, error) {
users, err := s.back.Gets()
if err != nil {
return nil, err
}
for _, user := range users {
- if err := user.Clean(baseScope); err != nil {
+ if err := user.Clean(baseScope, followExternalSymlinks); err != nil {
return nil, err
}
}
@@ -74,7 +83,7 @@ func (s *Storage) Gets(baseScope string) ([]*User, error) {
// Update updates a user in the database.
func (s *Storage) Update(user *User, fields ...string) error {
- err := user.Clean("", fields...)
+ err := user.Clean("", false, fields...)
if err != nil {
return err
}
@@ -92,7 +101,7 @@ func (s *Storage) Update(user *User, fields ...string) error {
// Save saves the user in a storage.
func (s *Storage) Save(user *User) error {
- if err := user.Clean(""); err != nil {
+ if err := user.Clean("", false); err != nil {
return err
}
diff --git a/users/users.go b/users/users.go
index 6be3b09d..09db995a 100644
--- a/users/users.go
+++ b/users/users.go
@@ -19,23 +19,23 @@ const (
// User describes a user.
type User struct {
- ID uint `storm:"id,increment" json:"id"`
- Username string `storm:"unique" json:"username"`
- Password string `json:"password"`
- Scope string `json:"scope"`
- Locale string `json:"locale"`
- LockPassword bool `json:"lockPassword"`
- ViewMode ViewMode `json:"viewMode"`
- SingleClick bool `json:"singleClick"`
- RedirectAfterCopyMove bool `json:"redirectAfterCopyMove"`
- Perm Permissions `json:"perm"`
- Commands []string `json:"commands"`
- Sorting files.Sorting `json:"sorting"`
- Fs *files.ScopedFs `json:"-" yaml:"-"`
- Rules []rules.Rule `json:"rules"`
- HideDotfiles bool `json:"hideDotfiles"`
- DateFormat bool `json:"dateFormat"`
- AceEditorTheme string `json:"aceEditorTheme"`
+ ID uint `storm:"id,increment" json:"id"`
+ Username string `storm:"unique" json:"username"`
+ Password string `json:"password"`
+ Scope string `json:"scope"`
+ Locale string `json:"locale"`
+ LockPassword bool `json:"lockPassword"`
+ ViewMode ViewMode `json:"viewMode"`
+ SingleClick bool `json:"singleClick"`
+ RedirectAfterCopyMove bool `json:"redirectAfterCopyMove"`
+ Perm Permissions `json:"perm"`
+ Commands []string `json:"commands"`
+ Sorting files.Sorting `json:"sorting"`
+ Fs afero.Fs `json:"-" yaml:"-"`
+ Rules []rules.Rule `json:"rules"`
+ HideDotfiles bool `json:"hideDotfiles"`
+ DateFormat bool `json:"dateFormat"`
+ AceEditorTheme string `json:"aceEditorTheme"`
}
// GetRules implements rules.Provider.
@@ -55,7 +55,7 @@ var checkableFields = []string{
// Clean cleans up a user and verifies if all its fields
// are alright to be saved.
-func (u *User) Clean(baseScope string, fields ...string) error {
+func (u *User) Clean(baseScope string, followExternalSymlinks bool, fields ...string) error {
if len(fields) == 0 {
fields = checkableFields
}
@@ -92,7 +92,7 @@ func (u *User) Clean(baseScope string, fields ...string) error {
if u.Fs == nil {
scope := u.Scope
scope = filepath.Join(baseScope, filepath.Join("/", scope))
- u.Fs = files.NewScopedFs(afero.NewOsFs(), scope)
+ u.Fs = files.NewFs(afero.NewOsFs(), scope, followExternalSymlinks)
}
return nil
@@ -100,5 +100,5 @@ func (u *User) Clean(baseScope string, fields ...string) error {
// FullPath gets the full path for a user's relative path.
func (u *User) FullPath(path string) string {
- return afero.FullBaseFsPath(u.Fs.Base(), path)
+ return afero.FullBaseFsPath(files.BasePath(u.Fs), path)
}
diff --git a/users/users_test.go b/users/users_test.go
new file mode 100644
index 00000000..87171aaf
--- /dev/null
+++ b/users/users_test.go
@@ -0,0 +1,43 @@
+package users
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/filebrowser/filebrowser/v2/files"
+ "github.com/spf13/afero"
+)
+
+// TestUserCleanFs verifies that Clean builds the user filesystem according to the
+// followExternalSymlinks flag and that FullPath resolves correctly for either
+// implementation.
+func TestUserCleanFs(t *testing.T) {
+ base := t.TempDir()
+ want := filepath.Join(base, "data", "x")
+
+ t.Run("default builds a symlink-confining ScopedFs", func(t *testing.T) {
+ u := &User{Username: "u", Password: "p", Scope: "data"}
+ if err := u.Clean(base, false); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := u.Fs.(*files.ScopedFs); !ok {
+ t.Fatalf("expected *files.ScopedFs, got %T", u.Fs)
+ }
+ if got := u.FullPath("/x"); got != want {
+ t.Fatalf("FullPath: got %q, want %q", got, want)
+ }
+ })
+
+ t.Run("followExternalSymlinks builds a bare BasePathFs", func(t *testing.T) {
+ u := &User{Username: "u", Password: "p", Scope: "data"}
+ if err := u.Clean(base, true); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := u.Fs.(*afero.BasePathFs); !ok {
+ t.Fatalf("expected *afero.BasePathFs, got %T", u.Fs)
+ }
+ if got := u.FullPath("/x"); got != want {
+ t.Fatalf("FullPath: got %q, want %q", got, want)
+ }
+ })
+}
diff --git a/www/docs/authentication.md b/www/docs/authentication.md
index 4308b01c..24833f04 100644
--- a/www/docs/authentication.md
+++ b/www/docs/authentication.md
@@ -46,6 +46,10 @@ The Hook Authentication method in FileBrowser allows developers to delegate user
The hook’s output controls user permissions, scope, locale, and other attributes, making it a powerful and extensible authentication mechanism.
+> [!WARNING]
+>
+> The submitted username and password are attacker-controlled and are handed to your hook command as the `USERNAME` and `PASSWORD` environment variables. File Browser runs the command directly, without a shell, so the values themselves are inert. However, your script must treat them as untrusted: always quote them (`"$USERNAME"`, `"$PASSWORD"`) and never pass them unquoted to a shell, `eval`, `bash -c`, command substitution, or backticks. A hook script that shell-evaluates these values turns any login request into remote code execution.
+
For example, the following code delegates filebrowser authentication to a PowerShell script on Windows. You can configure any command (for example, a script in Python, Node.js, etc.).
```sh
diff --git a/www/docs/cli/filebrowser-config-init.md b/www/docs/cli/filebrowser-config-init.md
index bf13379e..c4e463cc 100644
--- a/www/docs/cli/filebrowser-config-init.md
+++ b/www/docs/cli/filebrowser-config-init.md
@@ -41,6 +41,7 @@ filebrowser config init [flags]
--disableThumbnails disable image thumbnails
--disableTypeDetectionByHeader disables type detection by reading file headers
--fileMode string mode bits that new files are created with (default "0o640")
+ --followExternalSymlinks follow symlinks whose target is outside the user scope (unsafe)
-h, --help help for init
--hideDotfiles hide dotfiles in file listings
--hideLoginButton hide login button from public pages
diff --git a/www/docs/cli/filebrowser-config-set.md b/www/docs/cli/filebrowser-config-set.md
index e17f7058..0b8684e0 100644
--- a/www/docs/cli/filebrowser-config-set.md
+++ b/www/docs/cli/filebrowser-config-set.md
@@ -38,6 +38,7 @@ filebrowser config set [flags]
--disableThumbnails disable image thumbnails
--disableTypeDetectionByHeader disables type detection by reading file headers
--fileMode string mode bits that new files are created with (default "0o640")
+ --followExternalSymlinks follow symlinks whose target is outside the user scope (unsafe)
-h, --help help for set
--hideDotfiles hide dotfiles in file listings
--hideLoginButton hide login button from public pages
diff --git a/www/docs/cli/filebrowser.md b/www/docs/cli/filebrowser.md
index ae76fdd9..266516bf 100644
--- a/www/docs/cli/filebrowser.md
+++ b/www/docs/cli/filebrowser.md
@@ -61,6 +61,7 @@ filebrowser [flags]
--disablePreviewResize disable resize of image previews
--disableThumbnails disable image thumbnails
--disableTypeDetectionByHeader disables type detection by reading file headers
+ --followExternalSymlinks follow symlinks whose target is outside the user scope (unsafe)
-h, --help help for filebrowser
--imageProcessors int image processors count (default 4)
-k, --key string tls key
diff --git a/www/docs/deployment.md b/www/docs/deployment.md
index aa2968fc..57676004 100644
--- a/www/docs/deployment.md
+++ b/www/docs/deployment.md
@@ -1,3 +1,15 @@
+## Self-Registration (Signup)
+
+File Browser allows you to enable user self-registration (signup). This can be enabled via **Settings → Global Settings**, or with `filebrowser config set --signup`. Self-registered users inherit the configured **user defaults**, including the scope.
+
+> [!WARNING]
+>
+> By default, the user scope is the server's root, so a self-registered user could read,
+> modify, and delete every file File Browser serves. To prevent this, either:
+>
+> a. Enable `createUserDir` so each user gets their own directory; or
+> b. If users are meant to share files, set the default scope to something other than the root.
+
## Fail2ban
File Browser does not natively support protection against brute force attacks. Therefore, we suggest using something like [fail2ban](https://github.com/fail2ban/fail2ban), which takes care of that by tracking the logs of your File Browser instance. For more information on how fail2ban works, please refer to their [wiki](https://github.com/fail2ban/fail2ban/wiki).