From 64511ce45e3be379e965f7f4fb0929a068d5bb81 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Tue, 23 Jun 2026 12:35:55 +0200 Subject: [PATCH 01/24] fix: dangling symlink, write, delete scope bugs --- files/file_test.go | 119 ++++++++++++++++++++++++++++++++++++++++++ files/scoped.go | 63 +++++++++++++++++----- http/resource_test.go | 107 +++++++++++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 12 deletions(-) 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/scoped.go b/files/scoped.go index eef6378b..dcc27a12 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 @@ -61,13 +66,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 +78,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 +169,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/http/resource_test.go b/http/resource_test.go index 7081206f..e22d152b 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,108 @@ 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) + } +} From a1063925e15ef27f9d5dc26aae371bbf52af608c Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Tue, 23 Jun 2026 13:19:51 +0200 Subject: [PATCH 02/24] fix: restore symlink behavior as opt-in followExternalSymlinks --- auth/hook.go | 4 +- auth/json.go | 2 +- auth/none.go | 2 +- auth/proxy.go | 2 +- auth/proxy_test.go | 8 +-- cmd/config.go | 1 + cmd/root.go | 39 ++++++++---- cmd/rules.go | 2 +- cmd/users_export.go | 2 +- cmd/users_find.go | 6 +- cmd/users_import.go | 8 +-- cmd/users_update.go | 4 +- files/fs_test.go | 121 ++++++++++++++++++++++++++++++++++++ files/scoped.go | 27 +++++++- http/auth.go | 2 +- http/public.go | 13 ++-- http/public_symlink_test.go | 80 ++++++++++++++++++++++++ http/public_test.go | 18 ++++-- http/users.go | 6 +- settings/settings.go | 31 ++++----- users/storage.go | 16 ++--- users/users.go | 40 ++++++------ users/users_test.go | 43 +++++++++++++ 23 files changed, 384 insertions(+), 93 deletions(-) create mode 100644 files/fs_test.go create mode 100644 users/users_test.go 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..df520a2c 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,14 @@ 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) 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..d7f7ea50 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -111,6 +111,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 +354,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 +374,13 @@ 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.") + } + return server, nil } @@ -446,19 +458,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/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 dcc27a12..97ce32d0 100644 --- a/files/scoped.go +++ b/files/scoped.go @@ -37,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 diff --git a/http/auth.go b/http/auth.go index 4381e86c..137824d5 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 } 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/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/users/storage.go b/users/storage.go index 33cfc9c4..32f10e4d 100644 --- a/users/storage.go +++ b/users/storage.go @@ -19,8 +19,8 @@ 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) + Gets(baseScope string, followExternalSymlinks bool) ([]*User, error) Update(user *User, fields ...string) error Save(user *User) error Delete(id interface{}) error @@ -45,26 +45,26 @@ 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 } // 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 +74,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 +92,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) + } + }) +} From 8cfa6a175f428f89ef2c349e3d43166ee0c60135 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Tue, 23 Jun 2026 13:33:00 +0200 Subject: [PATCH 03/24] chore(docs): update CLI documentation --- www/docs/cli/filebrowser-config-init.md | 1 + www/docs/cli/filebrowser-config-set.md | 1 + www/docs/cli/filebrowser.md | 1 + 3 files changed, 3 insertions(+) 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 From bd1520fe095d2f373f8fbcbfc01f855ade7e90fb Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Tue, 23 Jun 2026 13:33:10 +0200 Subject: [PATCH 04/24] chore(release): 2.63.16 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5e61a5..c70074f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ 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.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) From 6209f8fddd0f279b4d57571c0d5bb114affc1942 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:19:42 +0200 Subject: [PATCH 05/24] chore: update translations (#5990) --- frontend/src/i18n/ko.json | 6 +- frontend/src/i18n/zh-cn.json | 194 +++++++++++++++++------------------ 2 files changed, 100 insertions(+), 100 deletions(-) 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/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": "链接已复制!" From d9cf2f0100d2c4892cad8e339eacca96df1aa5b6 Mon Sep 17 00:00:00 2001 From: Rayan Salhab Date: Sat, 27 Jun 2026 08:28:55 +0300 Subject: [PATCH 06/24] fix: preserve SRT subtitle line breaks (#6002) --- http/subtitle.go | 14 +++++++++- http/subtitle_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 http/subtitle_test.go 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) + } +} From 43a404ca69bf25553bfbbb2b446f0f53077c6302 Mon Sep 17 00:00:00 2001 From: JinHyuk Sung <163989462+sjh9714@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:43:22 +0900 Subject: [PATCH 07/24] fix: match admin share paths by owner scope (#5992) --- http/share.go | 30 +++++++++++- http/share_test.go | 110 ++++++++++++++++++++++++++++++++++++++++++ share/storage.go | 17 ------- share/storage_test.go | 85 -------------------------------- 4 files changed, 139 insertions(+), 103 deletions(-) create mode 100644 http/share_test.go delete mode 100644 share/storage_test.go diff --git a/http/share.go b/http/share.go index 7cd08faf..3d05fe05 100644 --- a/http/share.go +++ b/http/share.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "path/filepath" "sort" "strconv" "strings" @@ -14,6 +15,7 @@ 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" ) @@ -61,7 +63,7 @@ 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) } @@ -76,6 +78,32 @@ var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request return renderJSON(w, r, 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, "/") diff --git a/http/share_test.go b/http/share_test.go new file mode 100644 index 00000000..8029acc5 --- /dev/null +++ b/http/share_test.go @@ -0,0 +1,110 @@ +package fbhttp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "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) + } +} + +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/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) - } -} From 2472fbcd30502606feb11fbc8b8dc4f3803e6641 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 27 Jun 2026 08:01:26 +0200 Subject: [PATCH 08/24] fix: normalize recursive listing paths to forward slashes (#6003) --- .../utils/__tests__/check-conflict.test.ts | 48 +++++++++++++++++++ frontend/src/utils/upload.ts | 7 +-- http/resource.go | 5 +- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/frontend/src/utils/__tests__/check-conflict.test.ts b/frontend/src/utils/__tests__/check-conflict.test.ts index 37a4e69b..dcf3193c 100644 --- a/frontend/src/utils/__tests__/check-conflict.test.ts +++ b/frontend/src/utils/__tests__/check-conflict.test.ts @@ -220,4 +220,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..1c0b591e 100644 --- a/frontend/src/utils/upload.ts +++ b/frontend/src/utils/upload.ts @@ -62,9 +62,10 @@ export async function checkConflict( 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; + // A Windows server may return OS-native backslash separators; normalize to + // forward slashes so the prefix strip and key lookup line up. + const path = entry.path.replace(/\\/g, "/"); + const rel = path.startsWith(normBase) ? path.slice(normBase.length) : path; serverMap.set(rel.replace(/^\/+/, ""), entry); } diff --git a/http/resource.go b/http/resource.go index 728bad73..eda4e403 100644 --- a/http/resource.go +++ b/http/resource.go @@ -424,7 +424,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(), From 1fb05d65de98f8dc341409f40d382297ca75bcf0 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 27 Jun 2026 08:08:02 +0200 Subject: [PATCH 09/24] docs,cmd: warn about broad scope for self-signup users (GHSA-6759-996p-gpj6) When Signup is enabled with the default scope and createUserDir off, every self-registered user inherits the served root and can read/modify/delete all files. Add a startup WARNING for this configuration and document the risk and the --createUserDir mitigation. No behavior or default change. --- cmd/root.go | 13 +++++++++++++ www/docs/deployment.md | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index d7f7ea50..1d34f866 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -381,6 +382,18 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e 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 } 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). From 8503ba61ff51d48a7313896483d130eb6a5abfe0 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 27 Jun 2026 08:39:14 +0200 Subject: [PATCH 10/24] fix(raw): neutralize backslashes in archive entry names (GHSA-83xp-526h-j3ww) The fix for CVE-2026-54093 rewrote backslashes to the path separator "/" in archive entry names. On POSIX hosts a backslash is a legal filename byte, so that rewrite manufactured a traversal sequence ("..\..\x" -> "../../x") out of a single in-scope file, turning a Windows-only zip-slip into a cross-platform one. Neutralize backslashes to an inert character instead, and reject any entry whose name is not already a normalized root-relative path. Adds a regression test that downloads a folder containing a backslash-named file as a zip. --- http/raw.go | 20 +++++++++++----- http/raw_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) 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() From 883a36f02fcb69566a8628cb47f18fdc73348387 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 27 Jun 2026 09:08:56 +0200 Subject: [PATCH 11/24] fix(auth): reject signup when normalized home dir collides (GHSA-7rc3-g7h6-22m7) cleanUsername is many-to-one, so distinct usernames (e.g. "teamone/x" and "teamone-x") can normalize to the same home directory. With CreateUserDir enabled, the second registrant silently reused the first user's directory, breaking per-user isolation. Add a GetByScope lookup and reject a signup whose derived scope is already taken. The check is gated on CreateUserDir: when it is off, signups intentionally share the configured default scope. Adds a regression test for the colliding-username case. --- auth/proxy_test.go | 1 + http/auth.go | 16 ++++++++++ http/auth_test.go | 71 +++++++++++++++++++++++++++++++++++++++++++ storage/bolt/users.go | 14 +++++++++ users/storage.go | 9 ++++++ 5 files changed, 111 insertions(+) create mode 100644 http/auth_test.go diff --git a/auth/proxy_test.go b/auth/proxy_test.go index df520a2c..9b9ef3a7 100644 --- a/auth/proxy_test.go +++ b/auth/proxy_test.go @@ -22,6 +22,7 @@ func (m *mockUserStore) Get(_ string, _ bool, id interface{}) (*users.User, erro return nil, fberrors.ErrNotExist } +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 { diff --git a/http/auth.go b/http/auth.go index 137824d5..b96819b8 100644 --- a/http/auth.go +++ b/http/auth.go @@ -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/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 32f10e4d..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 @@ -20,6 +21,7 @@ type StorageBackend interface { type Store interface { 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 @@ -56,6 +58,13 @@ func (s *Storage) Get(baseScope string, followExternalSymlinks bool, id interfac 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, followExternalSymlinks bool) ([]*User, error) { users, err := s.back.Gets() From ec130546713c44cd24556907552ac554c7f809c9 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 27 Jun 2026 09:08:56 +0200 Subject: [PATCH 12/24] fix(share): stop exposing password hash and bypass token in share API (GHSA-833g-cqhp-h72j) The share management endpoints serialized the storage struct directly, returning the bcrypt password_hash (crackable offline) and the bypass token for every share an authenticated caller could list, with admins seeing them for all users. Return a response DTO that exposes only whether a share is password-protected (hasPassword) and drops both secrets. The storage struct keeps its tags so the secrets stay persisted and the server-side auth/public flows are unchanged. Updates the frontend to use hasPassword and adds a regression test. --- frontend/src/components/prompts/Share.vue | 2 +- frontend/src/types/api.d.ts | 2 +- http/share.go | 40 ++++++++++++++--- http/share_test.go | 54 +++++++++++++++++++++++ 4 files changed, 91 insertions(+), 7 deletions(-) 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/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/http/share.go b/http/share.go index 3d05fe05..8ed4feb4 100644 --- a/http/share.go +++ b/http/share.go @@ -19,6 +19,36 @@ import ( "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 { @@ -40,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 { @@ -54,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) { @@ -68,14 +98,14 @@ var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request 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) { @@ -204,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 index 8029acc5..c03a9393 100644 --- a/http/share_test.go +++ b/http/share_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -92,6 +93,59 @@ func TestAdminShareGetsHandlerMatchesOwnerScope(t *testing.T) { } } +// 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() From f30fca636c1af9ef401e9a82ff60391cb3db97e1 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 27 Jun 2026 09:08:57 +0200 Subject: [PATCH 13/24] fix(share): delete exact directory share on trailing-slash delete (GHSA-pp88-jhwj-5qh5) DeleteWithPathPrefix queried the share index with the raw path, so deleting a directory through a trailing-slash path (e.g. DELETE /api/resources/a/) only matched descendants like /a/child and missed the exact /a share, leaving it in storage. If the same path was later recreated, the stale public share re-exposed the new content. Normalize the path before the prefix query so the exact share and its descendants are both removed. Adds a regression test. --- storage/bolt/share.go | 7 ++++--- storage/bolt/share_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) 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() From d76b7d161099853f17e71b1327ce4545a30c27a2 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 27 Jun 2026 09:14:08 +0200 Subject: [PATCH 14/24] chore(release): 2.63.17 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c70074f4..8029b350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ 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.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) From c05ead7e8e23b6d6c9c9e11271bf8d5e74169f4a Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 27 Jun 2026 09:37:29 +0200 Subject: [PATCH 15/24] docs: warning about hook executor --- www/docs/authentication.md | 4 ++++ 1 file changed, 4 insertions(+) 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 From aac25166378422135e624e305c410c54a39374fb Mon Sep 17 00:00:00 2001 From: Aditya Raj Singh Date: Sat, 4 Jul 2026 11:39:10 +0530 Subject: [PATCH 16/24] fix(preview): keep the EPUB table-of-contents button clear of the header (#6010) --- frontend/src/css/epubReader.css | 7 +++++++ 1 file changed, 7 insertions(+) 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 { From dfc2e887e1a19d54984a0d7e39a2a63caf73ef19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E6=A1=89?= <161032298+014-code@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:13:31 +0800 Subject: [PATCH 17/24] fix: avoid recursive conflict checks for copy and move (#6009) --- .../__tests__/copy-move-conflict.test.ts | 4 +- .../utils/__tests__/check-conflict.test.ts | 54 ++++++++---- frontend/src/utils/upload.ts | 83 +++++++++++++------ 3 files changed, 97 insertions(+), 44 deletions(-) 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/utils/__tests__/check-conflict.test.ts b/frontend/src/utils/__tests__/check-conflict.test.ts index dcf3193c..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([ { diff --git a/frontend/src/utils/upload.ts b/frontend/src/utils/upload.ts index 1c0b591e..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,27 +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) { - // A Windows server may return OS-native backslash separators; normalize to - // forward slashes so the prefix strip and key lookup line up. - const path = entry.path.replace(/\\/g, "/"); - const rel = path.startsWith(normBase) ? path.slice(normBase.length) : path; - serverMap.set(rel.replace(/^\/+/, ""), entry); - } + const serverMap = buildConflictMap( + serverEntries, + basePath, + includeDirectories + ); const conflicts: ConflictingResource[] = []; files.forEach((file, index) => { @@ -78,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"], From 58d22578e8d7517420feda161fcb9d4749518690 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 4 Jul 2026 08:23:16 +0200 Subject: [PATCH 18/24] chore: update dependencies --- go.mod | 22 +++++++++++----------- go.sum | 44 ++++++++++++++++++++++---------------------- 2 files changed, 33 insertions(+), 33 deletions(-) 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= From 4470288ba1828a14453a99348fd22c62aa0b9460 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 4 Jul 2026 08:24:17 +0200 Subject: [PATCH 19/24] fix: deduplicate PT language --- frontend/src/i18n/pt.json | 309 -------------------------------------- 1 file changed, 309 deletions(-) delete mode 100644 frontend/src/i18n/pt.json 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" - } -} From 2651260a1ccab6d7b30e065de05992818acdb65b Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:24:37 +0200 Subject: [PATCH 20/24] chore: update translations --- frontend/src/i18n/pt-pt.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From fe7efb2e6afe66774cd86a5b0a03033bd514d0c0 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 4 Jul 2026 08:25:01 +0200 Subject: [PATCH 21/24] chore(release): 2.63.18 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8029b350..83fb21fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 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) From 9b78324d773c790951cc6a97840c4b55f66b5f3d Mon Sep 17 00:00:00 2001 From: tianrking <10758833+tianrking@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:14:41 +0800 Subject: [PATCH 22/24] fix(http): run upload hooks for directories (#6034) --- http/resource.go | 4 +++- http/resource_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/http/resource.go b/http/resource.go index eda4e403..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 } diff --git a/http/resource_test.go b/http/resource_test.go index e22d152b..5a29518d 100644 --- a/http/resource_test.go +++ b/http/resource_test.go @@ -222,3 +222,38 @@ func TestResourcePostCleanupDoesNotDeleteThroughSymlink(t *testing.T) { 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) + } +} From f0785391bf11cc8ec53bff7b2f36a29a02f536dc Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:20:43 +0200 Subject: [PATCH 23/24] chore: update translations (#6019) Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- frontend/src/i18n/fr.json | 50 +++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/frontend/src/i18n/fr.json b/frontend/src/i18n/fr.json index 985763f1..4f22d4df 100644 --- a/frontend/src/i18n/fr.json +++ b/frontend/src/i18n/fr.json @@ -120,12 +120,12 @@ "passwordsDontMatch": "Les mots de passe ne concordent pas", "signup": "S'inscrire", "submit": "Se connecter", - "username": "Utilisateur", - "usernameTaken": "Le nom d'utilisateur est déjà pris", + "username": "Identifiant", + "usernameTaken": "L'identifiant est déjà pris", "wrongCredentials": "Identifiants incorrects !", "passwordTooShort": "Le mot de passe doit contenir au moins {min} caractères", "logout_reasons": { - "inactivity": "Vous avez été déconnecté(e) en raison d'une inactivité prolongée." + "inactivity": "Vous avez été déconnecté'e en raison d'une inactivité prolongée." } }, "permanent": "Permanent", @@ -194,12 +194,12 @@ "settings": { "aceEditorTheme": "Éditeur de Thème Ace", "admin": "Admin", - "administrator": "Administrateur", + "administrator": "Administrateur'ice", "allowCommands": "Exécuter des commandes", "allowEdit": "Éditer, renommer et supprimer des fichiers ou des dossiers", "allowNew": "Créer de nouveaux fichiers et dossiers", "allowPublish": "Publier de nouveaux posts et pages", - "allowSignup": "Autoriser les utilisateurs à s'inscrire", + "allowSignup": "Autoriser les utilisateur'ices à s'inscrire", "hideLoginButton": "Cacher le bouton d’identification sur les pages publiques", "avoidChanges": "(Laisser vide pour conserver l'actuel)", "branding": "Image de marque", @@ -209,34 +209,34 @@ "commandRunner": "Exécuteur de commandes", "commandRunnerHelp": "Ici, vous pouvez définir les commandes qui seront exécutées lors des événements nommés précédemments. Vous devez en écrire une par ligne. Les variables d'environnement {0} et {1} seront disponibles, {0} étant relatif à {1}. Pour plus d'informations sur cette fonctionnalité et les variables d'environnement disponibles, veuillez lire la {2}.", "commandsUpdated": "Commandes mises à jour !", - "createUserDir": "Créer automatiquement un dossier pour l'utilisateur", + "createUserDir": "Créer automatiquement un dossier pour l'utilisateur'ice", "minimumPasswordLength": "Taille minimale du mot de passe", "tusUploads": "Uploads segmentés", "tusUploadsHelp": "File Browser prend en charge les uploads segmentés afin de permettre une gestion efficace, fiable et reprenable sur des réseaux instables.", "tusUploadsChunkSize": "Taille maximale autorisée par segment (les uploads directs seront utilisés pour les fichiers plus petits). Vous pouvez entrer un entier en octets ou une chaîne telle que 10MB, 1GB, etc.", "tusUploadsRetryCount": "Nombre de tentatives en cas d'échec d'un segment.", - "userHomeBasePath": "Chemin de base pour les dossiers personnels des utilisateurs", + "userHomeBasePath": "Chemin de base pour les dossiers personnels des utilisateur'ices", "userScopeGenerationPlaceholder": "Le périmètre sera généré automatiquement", - "createUserHomeDirectory": "Créer le dossier personnel de l'utilisateur", + "createUserHomeDirectory": "Créer le dossier personnel de l'utilisateur'ice", "customStylesheet": "Feuille de style personnalisée", - "defaultUserDescription": "Paramètres par défaut pour les nouveaux utilisateurs.", + "defaultUserDescription": "Paramètres par défaut pour les nouveaux utilisateur'ices.", "disableExternalLinks": "Désactiver les liens externes (sauf la documentation)", "disableUsedDiskPercentage": "Désactiver le graphique de pourcentage d'utilisation du disque", "documentation": "documentation", "examples": "Exemples", "executeOnShell": "Exécuter dans le shell", - "executeOnShellDescription": "Par défaut, File Browser exécute les commandes en appelant directement leurs binaires. Si vous voulez les exécuter sur un shell à la place (comme Bash ou PowerShell), vous pouvez le définir ici avec les arguments et les drapeaux requis. S'il est défini, la commande que vous exécutez sera ajoutée en tant qu'argument. Cela s'applique à la fois aux commandes utilisateur et aux crochets d'événements.", - "globalRules": "Il s'agit d'un ensemble global de règles d'autorisation et d'interdiction. Elles s'appliquent à tous les utilisateurs. Vous pouvez définir des règles spécifiques sur les paramètres de chaque utilisateur pour remplacer celles-ci.", + "executeOnShellDescription": "Par défaut, File Browser exécute les commandes en appelant directement leurs binaires. Si vous voulez les exécuter sur un shell à la place (comme Bash ou PowerShell), vous pouvez le définir ici avec les arguments et les drapeaux requis. S'il est défini, la commande que vous exécutez sera ajoutée en tant qu'argument. Cela s'applique à la fois aux commandes utilisateur'ice et aux crochets d'événements.", + "globalRules": "Il s'agit d'un ensemble global de règles d'autorisation et d'interdiction. Elles s'appliquent à tous les utilisateur'ices. Vous pouvez définir des règles spécifiques sur les paramètres de chaque utilisateur'ice pour remplacer celles-ci.", "globalSettings": "Paramètres globaux", "hideDotfiles": "Cacher les fichiers de configuration commançant par un point", "insertPath": "Insérer le chemin", "insertRegex": "Insérer une expression régulière", "instanceName": "Nom de l'instance", "language": "Langue", - "lockPassword": "Empêcher l'utilisateur de changer son mot de passe", + "lockPassword": "Empêcher l'utilisateur'ice de changer son mot de passe", "newPassword": "Votre nouveau mot de passe", "newPasswordConfirm": "Confirmation du nouveau mot de passe", - "newUser": "Nouvel utilisateur", + "newUser": "Nouvel'le utilisateur'ice", "password": "Mot de passe", "passwordUpdated": "Mot de passe mis à jour !", "path": "Chemin", @@ -250,14 +250,14 @@ "share": "Partager des fichiers (autorisation de téléchargement requise)" }, "permissions": "Permissions", - "permissionsHelp": "Vous pouvez définir l'utilisateur comme étant un administrateur ou encore choisir les permissions individuellement. Si vous sélectionnez \"Administrateur\", toutes les autres options seront automatiquement activées. La gestion des utilisateurs est un privilège que seul l'administrateur possède.\n", + "permissionsHelp": "Vous pouvez définir l'utilisateur'ice comme étant un'e administrateur'ice ou encore choisir les permissions individuellement. Si vous sélectionnez \"Administrateur'ice\", toutes les autres options seront automatiquement activées. La gestion des utilisateur'ices est un privilège que seul l'administrateur'ice possède.\n", "profileSettings": "Paramètres du profil", "redirectAfterCopyMove": "Rediriger vers la destination après une copie/déplacement", "ruleExample1": "Bloque l'accès à tous les fichiers commençant par un point (comme par exemple .git, .gitignore) dans tous les dossiers.\n", - "ruleExample2": "Bloque l'accès au fichier nommé \"Caddyfile\" à la racine du dossier utilisateur", + "ruleExample2": "Bloque l'accès au fichier nommé \"Caddyfile\" à la racine du dossier utilisateur'ice", "rules": "Règles", - "rulesHelp": "Vous pouvez définir ici un ensemble de règles pour cet utilisateur. Les fichiers bloqués ne seront pas affichés et ne seront pas accessibles par l'utilisateur. Les expressions régulières sont supportées et les chemins d'accès sont relatifs par rapport au dossier de l'utilisateur.\n", - "scope": "Portée du dossier utilisateur", + "rulesHelp": "Vous pouvez définir ici un ensemble de règles pour cet'te utilisateur'ice. Les fichiers bloqués ne seront pas affichés et ne seront pas accessibles par l'utilisateur'ice. Les expressions régulières sont supportées et les chemins d'accès sont relatifs par rapport au dossier de l'utilisateur'ice.\n", + "scope": "Portée du dossier utilisateur'ice", "setDateFormat": "Définir le format de la date", "settingsUpdated": "Les paramètres ont été mis à jour !", "shareDuration": "Durée du partage", @@ -270,16 +270,16 @@ "light": "Clair", "title": "Thème" }, - "user": "Utilisateur", + "user": "Utilisateur'ice", "userCommands": "Commandes", "userCommandsHelp": "Une liste séparée par des espaces des commandes permises pour l'utilisateur. Exemple :\n", - "userCreated": "Utilisateur créé !", - "userDefaults": "Paramètres par défaut de l'utilisateur", - "userDeleted": "Utilisateur supprimé !", - "userManagement": "Gestion des utilisateurs", - "userUpdated": "Utilisateur mis à jour !", - "username": "Nom d'utilisateur", - "users": "Utilisateurs", + "userCreated": "Utilisateur'ice créé !", + "userDefaults": "Paramètres par défaut de l'utilisateur'ice", + "userDeleted": "Utilisateur'ice supprimé !", + "userManagement": "Gestion des utilisateur'ices", + "userUpdated": "Utilisateur'ice mis à jour !", + "username": "Nom d'utilisateur'ice", + "users": "Utilisateur'ices", "currentPassword": "Mot de Passe Actuel" }, "sidebar": { From ac46cf06719575477d5125e7472037c204b3702d Mon Sep 17 00:00:00 2001 From: TowyTowy <85077986+TowyTowy@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:21:44 +0200 Subject: [PATCH 24/24] fix: return error instead of panicking on an unreadable directory during copy (#6020) Co-authored-by: Claude Fable 5 --- fileutils/copy_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++ fileutils/dir.go | 7 +++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/fileutils/copy_test.go b/fileutils/copy_test.go index ccdcc901..6899e032 100644 --- a/fileutils/copy_test.go +++ b/fileutils/copy_test.go @@ -2,6 +2,7 @@ package fileutils import ( "os" + "path" "path/filepath" "testing" @@ -9,6 +10,54 @@ import ( "github.com/spf13/afero" ) +// failingOpenFs wraps an afero.Fs and makes Open fail for one specific path, +// while every other operation (including Stat) is delegated unchanged. It +// simulates a directory that can be stat-ed but not opened/read — for example +// an unreadable sub-directory, or one whose permissions changed or that was +// removed after its parent was listed (a TOCTOU race) — encountered during a +// recursive copy. +type failingOpenFs struct { + afero.Fs + failOpen string +} + +func (f *failingOpenFs) Open(name string) (afero.File, error) { + if path.Clean(name) == path.Clean(f.failOpen) { + return nil, os.ErrPermission + } + return f.Fs.Open(name) +} + +// CopyDir is documented to keep going when it hits an error and to report the +// error afterwards. A sub-directory that cannot be opened must therefore yield +// an error (and leave the other, readable entries copied) rather than +// panicking on a nil directory handle. +func TestCopyDirUnreadableSubdirReturnsError(t *testing.T) { + mem := afero.NewMemMapFs() + if err := mem.MkdirAll("/srcdir/sub", 0o755); err != nil { + t.Fatal(err) + } + if err := afero.WriteFile(mem, "/srcdir/ok.txt", []byte("readable"), 0o644); err != nil { + t.Fatal(err) + } + + afs := &failingOpenFs{Fs: mem, failOpen: "/srcdir/sub"} + + err := Copy(afs, "/srcdir", "/dstdir", 0o644, 0o755) + if err == nil { + t.Fatal("expected an error when a sub-directory cannot be opened") + } + + // The readable sibling must still have been copied (continue-on-error). + data, readErr := afero.ReadFile(afs, "/dstdir/ok.txt") + if readErr != nil { + t.Fatalf("readable sibling was not copied: %v", readErr) + } + if string(data) != "readable" { + t.Fatalf("unexpected copied content: %q", string(data)) + } +} + // Copying an in-scope directory that contains a symlink whose target escapes // the user's scope must not dereference that symlink into the destination. // Otherwise a scoped user could exfiltrate out-of-scope file content via the diff --git a/fileutils/dir.go b/fileutils/dir.go index e0b049db..4bd7c925 100644 --- a/fileutils/dir.go +++ b/fileutils/dir.go @@ -23,7 +23,12 @@ func CopyDir(afs afero.Fs, source, dest string, fileMode, dirMode fs.FileMode) e return err } - dir, _ := afs.Open(source) + dir, err := afs.Open(source) + if err != nil { + return err + } + defer dir.Close() + obs, err := dir.Readdir(-1) if err != nil { return err