diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8c5c235..83fb21fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,76 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
+## [2.63.18](https://github.com/filebrowser/filebrowser/compare/v2.63.17...v2.63.18) (2026-07-04)
+
+
+### Bug Fixes
+
+* avoid recursive conflict checks for copy and move ([#6009](https://github.com/filebrowser/filebrowser/issues/6009)) ([dfc2e88](https://github.com/filebrowser/filebrowser/commit/dfc2e887e1a19d54984a0d7e39a2a63caf73ef19))
+* deduplicate PT language ([4470288](https://github.com/filebrowser/filebrowser/commit/4470288ba1828a14453a99348fd22c62aa0b9460))
+* **preview:** keep the EPUB table-of-contents button clear of the header ([#6010](https://github.com/filebrowser/filebrowser/issues/6010)) ([aac2516](https://github.com/filebrowser/filebrowser/commit/aac25166378422135e624e305c410c54a39374fb))
+
+## [2.63.17](https://github.com/filebrowser/filebrowser/compare/v2.63.16...v2.63.17) (2026-06-27)
+
+
+### Bug Fixes
+
+* **auth:** reject signup when normalized home dir collides (GHSA-7rc3-g7h6-22m7) ([883a36f](https://github.com/filebrowser/filebrowser/commit/883a36f02fcb69566a8628cb47f18fdc73348387))
+* match admin share paths by owner scope ([#5992](https://github.com/filebrowser/filebrowser/issues/5992)) ([43a404c](https://github.com/filebrowser/filebrowser/commit/43a404ca69bf25553bfbbb2b446f0f53077c6302))
+* normalize recursive listing paths to forward slashes ([#6003](https://github.com/filebrowser/filebrowser/issues/6003)) ([2472fbc](https://github.com/filebrowser/filebrowser/commit/2472fbcd30502606feb11fbc8b8dc4f3803e6641))
+* preserve SRT subtitle line breaks ([#6002](https://github.com/filebrowser/filebrowser/issues/6002)) ([d9cf2f0](https://github.com/filebrowser/filebrowser/commit/d9cf2f0100d2c4892cad8e339eacca96df1aa5b6))
+* **raw:** neutralize backslashes in archive entry names (GHSA-83xp-526h-j3ww) ([8503ba6](https://github.com/filebrowser/filebrowser/commit/8503ba61ff51d48a7313896483d130eb6a5abfe0))
+* **share:** delete exact directory share on trailing-slash delete (GHSA-pp88-jhwj-5qh5) ([f30fca6](https://github.com/filebrowser/filebrowser/commit/f30fca636c1af9ef401e9a82ff60391cb3db97e1))
+* **share:** stop exposing password hash and bypass token in share API (GHSA-833g-cqhp-h72j) ([ec13054](https://github.com/filebrowser/filebrowser/commit/ec130546713c44cd24556907552ac554c7f809c9))
+
+## [2.63.16](https://github.com/filebrowser/filebrowser/compare/v2.63.15...v2.63.16) (2026-06-23)
+
+
+### Bug Fixes
+
+* dangling symlink, write, delete scope bugs ([64511ce](https://github.com/filebrowser/filebrowser/commit/64511ce45e3be379e965f7f4fb0929a068d5bb81))
+* restore symlink behavior as opt-in followExternalSymlinks ([a106392](https://github.com/filebrowser/filebrowser/commit/a1063925e15ef27f9d5dc26aae371bbf52af608c))
+
+## [2.63.15](https://github.com/filebrowser/filebrowser/compare/v2.63.14...v2.63.15) (2026-06-13)
+
+
+### Bug Fixes
+
+* restore ScopedFs RealPath ([#5986](https://github.com/filebrowser/filebrowser/issues/5986)) ([403d2bb](https://github.com/filebrowser/filebrowser/commit/403d2bbd3357aa57e78745b52dcdad3490901bb3))
+
+## [2.63.14](https://github.com/filebrowser/filebrowser/compare/v2.63.13...v2.63.14) (2026-06-07)
+
+
+### Bug Fixes
+
+* recursive check ([3406d3d](https://github.com/filebrowser/filebrowser/commit/3406d3d7f98dfc3c16e4ff7ff4a87e3bdfe221dd))
+
+
+### Refactorings
+
+* ScopedFs to avoid escaping symlinks ([7c2c0a1](https://github.com/filebrowser/filebrowser/commit/7c2c0a11b31b2bb214d741005a0b02b1764208b3))
+
+## [2.63.13](https://github.com/filebrowser/filebrowser/compare/v2.63.12...v2.63.13) (2026-06-06)
+
+
+### Bug Fixes
+
+* copy/move allow overwrite ([a1a514d](https://github.com/filebrowser/filebrowser/commit/a1a514dcbb216d2080412c5354eea1e1fb033050))
+
+
+### Refactorings
+
+* cleanup and simplify upload.ts ([5f7311d](https://github.com/filebrowser/filebrowser/commit/5f7311d32437e98d7c14c7b307a4f68109275535))
+
+## [2.63.12](https://github.com/filebrowser/filebrowser/compare/v2.63.11...v2.63.12) (2026-06-04)
+
+
+### Bug Fixes
+
+* await copy move conflict detection ([#5978](https://github.com/filebrowser/filebrowser/issues/5978)) ([c1abe8f](https://github.com/filebrowser/filebrowser/commit/c1abe8f561208bf36bde70879d1a15ef9de998fa))
+* keep mobile file sort controls visible ([#5977](https://github.com/filebrowser/filebrowser/issues/5977)) ([0bb2768](https://github.com/filebrowser/filebrowser/commit/0bb2768754d11b865d68e72dcd7cebb232a6308a))
+* skip inaccessible children when listing directories ([#5958](https://github.com/filebrowser/filebrowser/issues/5958)) ([7b7ff8a](https://github.com/filebrowser/filebrowser/commit/7b7ff8ae8f97393b2e6ae6e061c1f780077c32b6))
+
## [2.63.11](https://github.com/filebrowser/filebrowser/compare/v2.63.10...v2.63.11) (2026-06-04)
diff --git a/auth/hook.go b/auth/hook.go
index 60c75461..a6dc25b8 100644
--- a/auth/hook.go
+++ b/auth/hook.go
@@ -68,7 +68,7 @@ func (a *HookAuth) Auth(r *http.Request, usr users.Store, stg *settings.Settings
case "block":
return nil, os.ErrPermission
case "pass":
- u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
+ u, err := a.Users.Get(a.Server.Root, a.Server.FollowExternalSymlinks, a.Cred.Username)
if err != nil || !users.CheckPwd(a.Cred.Password, u.Password) {
return nil, os.ErrPermission
}
@@ -129,7 +129,7 @@ func (a *HookAuth) GetValues(s string) {
// SaveUser updates the existing user or creates a new one when not found
func (a *HookAuth) SaveUser() (*users.User, error) {
- u, err := a.Users.Get(a.Server.Root, a.Cred.Username)
+ u, err := a.Users.Get(a.Server.Root, a.Server.FollowExternalSymlinks, a.Cred.Username)
if err != nil && !errors.Is(err, fberrors.ErrNotExist) {
return nil, err
}
diff --git a/auth/json.go b/auth/json.go
index 2284dc7f..be8ad1dc 100644
--- a/auth/json.go
+++ b/auth/json.go
@@ -55,7 +55,7 @@ func (a JSONAuth) Auth(r *http.Request, usr users.Store, _ *settings.Settings, s
}
}
- u, err := usr.Get(srv.Root, cred.Username)
+ u, err := usr.Get(srv.Root, srv.FollowExternalSymlinks, cred.Username)
hash := dummyHash
if err == nil {
diff --git a/auth/none.go b/auth/none.go
index c9381a83..30ea7129 100644
--- a/auth/none.go
+++ b/auth/none.go
@@ -15,7 +15,7 @@ type NoAuth struct{}
// Auth uses authenticates user 1.
func (a NoAuth) Auth(_ *http.Request, usr users.Store, _ *settings.Settings, srv *settings.Server) (*users.User, error) {
- return usr.Get(srv.Root, uint(1))
+ return usr.Get(srv.Root, srv.FollowExternalSymlinks, uint(1))
}
// LoginPage tells that no auth doesn't require a login page.
diff --git a/auth/proxy.go b/auth/proxy.go
index 57eddd4a..ab6227d4 100644
--- a/auth/proxy.go
+++ b/auth/proxy.go
@@ -20,7 +20,7 @@ type ProxyAuth struct {
// Auth authenticates the user via an HTTP header.
func (a ProxyAuth) Auth(r *http.Request, usr users.Store, setting *settings.Settings, srv *settings.Server) (*users.User, error) {
username := r.Header.Get(a.Header)
- user, err := usr.Get(srv.Root, username)
+ user, err := usr.Get(srv.Root, srv.FollowExternalSymlinks, username)
if errors.Is(err, fberrors.ErrNotExist) {
return a.createUser(usr, setting, srv, username)
}
diff --git a/auth/proxy_test.go b/auth/proxy_test.go
index 86fb7102..9b9ef3a7 100644
--- a/auth/proxy_test.go
+++ b/auth/proxy_test.go
@@ -13,7 +13,7 @@ type mockUserStore struct {
users map[string]*users.User
}
-func (m *mockUserStore) Get(_ string, id interface{}) (*users.User, error) {
+func (m *mockUserStore) Get(_ string, _ bool, id interface{}) (*users.User, error) {
if v, ok := id.(string); ok {
if u, ok := m.users[v]; ok {
return u, nil
@@ -22,14 +22,15 @@ func (m *mockUserStore) Get(_ string, id interface{}) (*users.User, error) {
return nil, fberrors.ErrNotExist
}
-func (m *mockUserStore) Gets(_ string) ([]*users.User, error) { return nil, nil }
-func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil }
+func (m *mockUserStore) GetByScope(_ string) (*users.User, error) { return nil, fberrors.ErrNotExist }
+func (m *mockUserStore) Gets(_ string, _ bool) ([]*users.User, error) { return nil, nil }
+func (m *mockUserStore) Update(_ *users.User, _ ...string) error { return nil }
func (m *mockUserStore) Save(user *users.User) error {
m.users[user.Username] = user
return nil
}
func (m *mockUserStore) Delete(_ interface{}) error { return nil }
-func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 }
+func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 }
func TestProxyAuthCreateUserRestrictsDefaults(t *testing.T) {
t.Parallel()
diff --git a/cmd/config.go b/cmd/config.go
index e3bb2b86..cf923eac 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -229,6 +229,7 @@ func printSettings(ser *settings.Server, set *settings.Settings, auther auth.Aut
fmt.Fprintf(w, "\tThumbnails Enabled:\t%t\n", ser.EnableThumbnails)
fmt.Fprintf(w, "\tResize Preview:\t%t\n", ser.ResizePreview)
fmt.Fprintf(w, "\tType Detection by Header:\t%t\n", ser.TypeDetectionByHeader)
+ fmt.Fprintf(w, "\tFollow External Symlinks:\t%t\n", ser.FollowExternalSymlinks)
fmt.Fprintln(w, "\nTUS:")
fmt.Fprintf(w, "\tChunk size:\t%d\n", set.Tus.ChunkSize)
diff --git a/cmd/root.go b/cmd/root.go
index 13139a7e..1d34f866 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -13,6 +13,7 @@ import (
"os"
"os/signal"
"path/filepath"
+ "strings"
"syscall"
"time"
@@ -111,6 +112,7 @@ func addServerFlags(flags *pflag.FlagSet) {
flags.Bool("disableExec", true, "disables Command Runner feature")
flags.Bool("disableTypeDetectionByHeader", false, "disables type detection by reading file headers")
flags.Bool("disableImageResolutionCalc", false, "disables image resolution calculation by reading image files")
+ flags.Bool("followExternalSymlinks", false, "follow symlinks whose target is outside the user scope (unsafe)")
}
var rootCmd = &cobra.Command{
@@ -353,6 +355,10 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
server.EnableExec = !v.GetBool("disableExec")
}
+ if v.IsSet("followExternalSymlinks") {
+ server.FollowExternalSymlinks = v.GetBool("followExternalSymlinks")
+ }
+
if isAddrSet && isSocketSet {
return nil, errors.New("--socket flag cannot be used with --address, --port, --key nor --cert")
}
@@ -369,6 +375,25 @@ func getServerSettings(v *viper.Viper, st *storage.Storage) (*settings.Server, e
log.Println("WARNING: read https://github.com/filebrowser/filebrowser/issues/5199")
}
+ if server.FollowExternalSymlinks {
+ log.Println("WARNING: Following external symlinks enabled!")
+ log.Println("WARNING: Symlinks pointing outside a user's scope will be followed,")
+ log.Println("WARNING: which can expose files outside that scope. Only enable this if")
+ log.Println("WARNING: you fully understand and trust the contents of every user scope.")
+ }
+
+ if set, err := st.Settings.Get(); err == nil && set.Signup {
+ scope := strings.TrimSpace(set.Defaults.Scope)
+ scopeIsRoot := scope == "" || scope == "." || scope == "/"
+
+ if !set.CreateUserDir && scopeIsRoot {
+ log.Println("WARNING: Signup is enabled without createUserDir and the default scope is")
+ log.Println("WARNING: the server root, so every self-registered user can read, modify and")
+ log.Println("WARNING: delete all files File Browser serves, including other users' files.")
+ log.Println("WARNING: Enable createUserDir, or set a default scope other than the root.")
+ }
+ }
+
return server, nil
}
@@ -446,19 +471,20 @@ func quickSetup(v *viper.Viper, s *storage.Storage) error {
}
ser := &settings.Server{
- BaseURL: v.GetString("baseURL"),
- Port: v.GetString("port"),
- Log: v.GetString("log"),
- TLSKey: v.GetString("key"),
- TLSCert: v.GetString("cert"),
- Address: v.GetString("address"),
- Root: v.GetString("root"),
- TokenExpirationTime: v.GetString("tokenExpirationTime"),
- EnableThumbnails: !v.GetBool("disableThumbnails"),
- ResizePreview: !v.GetBool("disablePreviewResize"),
- EnableExec: !v.GetBool("disableExec"),
- TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"),
- ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"),
+ BaseURL: v.GetString("baseURL"),
+ Port: v.GetString("port"),
+ Log: v.GetString("log"),
+ TLSKey: v.GetString("key"),
+ TLSCert: v.GetString("cert"),
+ Address: v.GetString("address"),
+ Root: v.GetString("root"),
+ TokenExpirationTime: v.GetString("tokenExpirationTime"),
+ EnableThumbnails: !v.GetBool("disableThumbnails"),
+ ResizePreview: !v.GetBool("disablePreviewResize"),
+ EnableExec: !v.GetBool("disableExec"),
+ TypeDetectionByHeader: !v.GetBool("disableTypeDetectionByHeader"),
+ ImageResolutionCal: !v.GetBool("disableImageResolutionCalc"),
+ FollowExternalSymlinks: v.GetBool("followExternalSymlinks"),
}
err = s.Settings.SaveServer(ser)
diff --git a/cmd/rules.go b/cmd/rules.go
index bdb1d1cf..f3e00bb6 100644
--- a/cmd/rules.go
+++ b/cmd/rules.go
@@ -36,7 +36,7 @@ func runRules(st *storage.Storage, cmd *cobra.Command, usersFn func(*users.User)
}
if id != nil {
var user *users.User
- user, err = st.Users.Get("", id)
+ user, err = st.Users.Get("", false, id)
if err != nil {
return err
}
diff --git a/cmd/users_export.go b/cmd/users_export.go
index 9bbec6d8..768c381f 100644
--- a/cmd/users_export.go
+++ b/cmd/users_export.go
@@ -15,7 +15,7 @@ var usersExportCmd = &cobra.Command{
path to the file where you want to write the users.`,
Args: jsonYamlArg,
RunE: withStore(func(_ *cobra.Command, args []string, st *store) error {
- list, err := st.Users.Gets("")
+ list, err := st.Users.Gets("", false)
if err != nil {
return err
}
diff --git a/cmd/users_find.go b/cmd/users_find.go
index 09bc8d47..fc91af86 100644
--- a/cmd/users_find.go
+++ b/cmd/users_find.go
@@ -36,14 +36,14 @@ var findUsers = withStore(func(_ *cobra.Command, args []string, st *store) error
if len(args) == 1 {
username, id := parseUsernameOrID(args[0])
if username != "" {
- user, err = st.Users.Get("", username)
+ user, err = st.Users.Get("", false, username)
} else {
- user, err = st.Users.Get("", id)
+ user, err = st.Users.Get("", false, id)
}
list = []*users.User{user}
} else {
- list, err = st.Users.Gets("")
+ list, err = st.Users.Gets("", false)
}
if err != nil {
diff --git a/cmd/users_import.go b/cmd/users_import.go
index 73effca6..0db3704f 100644
--- a/cmd/users_import.go
+++ b/cmd/users_import.go
@@ -40,7 +40,7 @@ list or set it to 0.`,
}
for _, user := range list {
- err = user.Clean("")
+ err = user.Clean("", false)
if err != nil {
return err
}
@@ -52,7 +52,7 @@ list or set it to 0.`,
}
if replace {
- oldUsers, userImportErr := st.Users.Gets("")
+ oldUsers, userImportErr := st.Users.Gets("", false)
if userImportErr != nil {
return userImportErr
}
@@ -76,7 +76,7 @@ list or set it to 0.`,
}
for _, user := range list {
- onDB, err := st.Users.Get("", user.ID)
+ onDB, err := st.Users.Get("", false, user.ID)
// User exists in DB.
if err == nil {
@@ -88,7 +88,7 @@ list or set it to 0.`,
// with the new username. If there is, print an error and cancel the
// operation
if user.Username != onDB.Username {
- if conflictuous, err := st.Users.Get("", user.Username); err == nil {
+ if conflictuous, err := st.Users.Get("", false, user.Username); err == nil {
return usernameConflictError(user.Username, conflictuous.ID, user.ID)
}
}
diff --git a/cmd/users_update.go b/cmd/users_update.go
index e9a484fc..12484342 100644
--- a/cmd/users_update.go
+++ b/cmd/users_update.go
@@ -43,9 +43,9 @@ options you want to change.`,
user *users.User
)
if id != 0 {
- user, err = st.Users.Get("", id)
+ user, err = st.Users.Get("", false, id)
} else {
- user, err = st.Users.Get("", username)
+ user, err = st.Users.Get("", false, username)
}
if err != nil {
return err
diff --git a/files/file.go b/files/file.go
index a034abc0..4102c3c4 100644
--- a/files/file.go
+++ b/files/file.go
@@ -18,13 +18,13 @@ import (
"path"
"path/filepath"
"regexp"
+ "sort"
"strings"
"time"
- "github.com/spf13/afero"
-
fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/rules"
+ "github.com/spf13/afero"
)
var (
@@ -128,13 +128,6 @@ func stat(opts *FileOptions) (*FileInfo, error) {
}
}
- if file != nil {
- ok, scopeErr := WithinScope(opts.Fs, opts.Path)
- if scopeErr != nil || !ok {
- return nil, os.ErrPermission
- }
- }
-
// regular file
if file != nil && !file.IsSymlink {
return file, nil
@@ -172,60 +165,6 @@ func stat(opts *FileOptions) (*FileInfo, error) {
return file, nil
}
-// WithinScope reports whether the on-disk target of p — after resolving any
-// symbolic links — stays within the scoped root of fsys. It exists to stop a
-// symlink that lives lexically inside a user's scope but points outside it
-// from being followed for reads, writes, or shares.
-//
-// 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. For a filesystem that is not scoped with BasePathFs the
-// check is a no-op and returns true.
-//
-// 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. Callers that create files
-// should treat this as best-effort and rely on rejecting existing escaping
-// symlinks, which covers the disclosure and overwrite vectors.
-func WithinScope(fsys afero.Fs, p string) (bool, error) {
- bfs, ok := fsys.(*afero.BasePathFs)
- if !ok {
- // Not a scoped filesystem; nothing to enforce.
- return true, nil
- }
-
- root, err := filepath.EvalSymlinks(afero.FullBaseFsPath(bfs, "/"))
- if err != nil {
- return false, err
- }
-
- target := afero.FullBaseFsPath(bfs, p)
- resolved, err := filepath.EvalSymlinks(target)
- for errors.Is(err, fs.ErrNotExist) {
- parent := filepath.Dir(target)
- if parent == target {
- break
- }
- target = parent
- resolved, err = filepath.EvalSymlinks(target)
- }
- if err != nil {
- return false, err
- }
-
- // Compare against root with a trailing separator so a sibling like
- // "/srvother" is not treated as being inside "/srv". When root is itself
- // the filesystem boundary (e.g. "/"), it already ends in a separator, so
- // avoid producing "//" — which no path would match — and accept any path
- // under it.
- prefix := root
- if !strings.HasSuffix(prefix, string(filepath.Separator)) {
- prefix += string(filepath.Separator)
- }
-
- return resolved == root || strings.HasPrefix(resolved, prefix), nil
-}
-
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func (i *FileInfo) Checksum(algo string) error {
@@ -452,8 +391,7 @@ func (i *FileInfo) addSubtitle(fPath string) {
}
func (i *FileInfo) readListing(checker rules.Checker, readHeader bool, calcImgRes bool) error {
- afs := &afero.Afero{Fs: i.Fs}
- dir, err := afs.ReadDir(i.Path)
+ dir, err := readDir(i.Fs, i.Path)
if err != nil {
return err
}
@@ -475,19 +413,19 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool, calcImgRe
isSymlink, isInvalidLink := false, false
if IsSymlink(f.Mode()) {
isSymlink = true
- // A symlink whose on-disk target escapes the scoped root must not be
- // followed, otherwise the listing would leak the target's metadata
- // (and downstream access) for files outside the user's scope or the
- // shared subtree.
- if ok, scopeErr := WithinScope(i.Fs, fPath); scopeErr != nil || !ok {
- continue
- }
- // It's a symbolic link. We try to follow it. If it doesn't work,
- // we stay with the link information instead of the target's.
+ // It's a symbolic link. We try to follow it. The scoped filesystem
+ // refuses to dereference a link whose target escapes the scope
+ // (permission error); such a link is omitted from the listing
+ // entirely so it cannot leak the target's metadata. Any other
+ // failure means a broken link, which we surface as an invalid link
+ // rather than the target's information.
info, err := i.Fs.Stat(fPath)
- if err == nil {
+ switch {
+ case err == nil:
f = info
- } else {
+ case errors.Is(err, os.ErrPermission):
+ continue
+ default:
isInvalidLink = true
}
}
@@ -535,3 +473,56 @@ func (i *FileInfo) readListing(checker rules.Checker, readHeader bool, calcImgRe
i.Listing = listing
return nil
}
+
+func readDir(afs afero.Fs, dirname string) ([]os.FileInfo, error) {
+ dir, err := afero.ReadDir(afs, dirname)
+ if err == nil {
+ return dir, nil
+ }
+
+ dir, fallbackErr := readDirNames(afs, dirname)
+ if fallbackErr != nil {
+ return nil, err
+ }
+
+ return dir, nil
+}
+
+func readDirNames(afs afero.Fs, dirname string) ([]os.FileInfo, error) {
+ file, err := afs.Open(dirname)
+ if err != nil {
+ return nil, err
+ }
+
+ names, err := file.Readdirnames(-1)
+ if closeErr := file.Close(); err == nil && closeErr != nil {
+ err = closeErr
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ sort.Strings(names)
+ dir := make([]os.FileInfo, 0, len(names))
+ for _, name := range names {
+ fPath := path.Join(dirname, name)
+ info, err := lstatIfPossible(afs, fPath)
+ if err != nil {
+ log.Printf("Skipping inaccessible file %s: %v", fPath, err)
+ continue
+ }
+
+ dir = append(dir, info)
+ }
+
+ return dir, nil
+}
+
+func lstatIfPossible(afs afero.Fs, name string) (os.FileInfo, error) {
+ if lstaterFs, ok := afs.(afero.Lstater); ok {
+ info, _, err := lstaterFs.LstatIfPossible(name)
+ return info, err
+ }
+
+ return afs.Stat(name)
+}
diff --git a/files/file_test.go b/files/file_test.go
index d1f647f3..45b114f6 100644
--- a/files/file_test.go
+++ b/files/file_test.go
@@ -2,60 +2,53 @@ package files
import (
"os"
+ "path"
"path/filepath"
"testing"
"github.com/spf13/afero"
)
-func TestWithinScope(t *testing.T) {
- t.Run("non-scoped filesystem is a no-op", func(t *testing.T) {
- ok, err := WithinScope(afero.NewOsFs(), "/anything")
- if err != nil || !ok {
- t.Fatalf("expected (true, nil), got (%v, %v)", ok, err)
- }
- })
-
- t.Run("path inside a nested scope is allowed", func(t *testing.T) {
+func TestScopedFs(t *testing.T) {
+ t.Run("path inside scope is allowed", func(t *testing.T) {
scope := t.TempDir()
if err := os.WriteFile(filepath.Join(scope, "file.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
- bfs := afero.NewBasePathFs(afero.NewOsFs(), scope)
+ fs := NewScopedFs(afero.NewOsFs(), scope)
- ok, err := WithinScope(bfs, "/file.txt")
- if err != nil || !ok {
- t.Fatalf("expected (true, nil), got (%v, %v)", ok, err)
+ if _, err := fs.Stat("/file.txt"); err != nil {
+ t.Fatalf("expected in-scope file to be accessible, got %v", err)
}
})
- t.Run("new file inside scope is allowed", func(t *testing.T) {
+ t.Run("new file inside scope can be created", func(t *testing.T) {
scope := t.TempDir()
- bfs := afero.NewBasePathFs(afero.NewOsFs(), scope)
+ fs := NewScopedFs(afero.NewOsFs(), scope)
- ok, err := WithinScope(bfs, "/does-not-exist-yet.txt")
- if err != nil || !ok {
- t.Fatalf("expected (true, nil), got (%v, %v)", ok, err)
+ f, err := fs.OpenFile("/does-not-exist-yet.txt", os.O_RDWR|os.O_CREATE, 0o644)
+ if err != nil {
+ t.Fatalf("expected to create a new in-scope file, got %v", err)
}
+ _ = f.Close()
})
// Regression for #5975: when the scope resolves to the filesystem root,
// root+separator used to be "//", which no path matched, so every write
// was rejected with os.ErrPermission (HTTP 403).
- t.Run("filesystem root scope allows writes", func(t *testing.T) {
+ t.Run("filesystem root scope allows access", func(t *testing.T) {
f := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
- bfs := afero.NewBasePathFs(afero.NewOsFs(), "/")
+ fs := NewScopedFs(afero.NewOsFs(), "/")
- ok, err := WithinScope(bfs, f)
- if err != nil || !ok {
- t.Fatalf("expected (true, nil) for a path under root scope, got (%v, %v)", ok, err)
+ if _, err := fs.Stat(f); err != nil {
+ t.Fatalf("expected a path under root scope to be accessible, got %v", err)
}
})
- t.Run("sibling of a nested scope is rejected", func(t *testing.T) {
+ t.Run("escaping symlink to a sibling is rejected", func(t *testing.T) {
base := t.TempDir()
scope := filepath.Join(base, "srv")
sibling := filepath.Join(base, "srvother")
@@ -64,20 +57,21 @@ func TestWithinScope(t *testing.T) {
t.Fatal(err)
}
}
- // A symlink lexically inside the scope pointing at a sibling directory
- // must not be followed.
- link := filepath.Join(scope, "escape")
- if err := os.Symlink(sibling, link); err != nil {
+ if err := os.WriteFile(filepath.Join(sibling, "secret.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
- bfs := afero.NewBasePathFs(afero.NewOsFs(), scope)
-
- ok, err := WithinScope(bfs, "/escape")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ // A symlink lexically inside the scope pointing at a sibling directory
+ // must not be followed for reads or stats.
+ if err := os.Symlink(sibling, filepath.Join(scope, "escape")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
}
- if ok {
- t.Fatal("expected escaping symlink to a sibling directory to be rejected")
+ fs := NewScopedFs(afero.NewOsFs(), scope)
+
+ if _, err := fs.Stat("/escape"); !os.IsPermission(err) {
+ t.Fatalf("expected stat of escaping symlink to be rejected, got %v", err)
+ }
+ if _, err := fs.Open("/escape/secret.txt"); !os.IsPermission(err) {
+ t.Fatalf("expected read through escaping symlink to be rejected, got %v", err)
}
})
@@ -92,11 +86,129 @@ func TestWithinScope(t *testing.T) {
if err := os.Symlink(filepath.Join(scope, "real"), filepath.Join(scope, "link")); err != nil {
t.Skipf("cannot create symlink: %v", err)
}
- bfs := afero.NewBasePathFs(afero.NewOsFs(), scope)
+ fs := NewScopedFs(afero.NewOsFs(), scope)
- ok, err := WithinScope(bfs, "/link/f.txt")
- if err != nil || !ok {
- t.Fatalf("expected (true, nil) for an in-scope symlink target, got (%v, %v)", ok, err)
+ if _, err := fs.Stat("/link/f.txt"); err != nil {
+ 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")
}
})
}
@@ -122,7 +234,7 @@ func TestStatRejectsLinkedAncestorEscape(t *testing.T) {
}
// Filesystem scoped to the shared directory, as a public share would be.
- bfs := afero.NewBasePathFs(afero.NewOsFs(), filepath.Join(scope, "shared"))
+ bfs := NewScopedFs(afero.NewOsFs(), filepath.Join(scope, "shared"))
if _, err := stat(&FileOptions{Fs: bfs, Path: "/link/secret.txt"}); !os.IsPermission(err) {
t.Fatalf("expected permission error for linked-ancestor escape, got %v", err)
@@ -131,3 +243,105 @@ func TestStatRejectsLinkedAncestorEscape(t *testing.T) {
t.Fatalf("expected in-scope file to be served, got %v", err)
}
}
+
+type allowAllChecker struct{}
+
+func (allowAllChecker) Check(string) bool {
+ return true
+}
+
+type inaccessibleChildFs struct {
+ afero.Fs
+ child string
+}
+
+func (fs inaccessibleChildFs) Open(name string) (afero.File, error) {
+ file, err := fs.Fs.Open(name)
+ if err != nil {
+ return nil, err
+ }
+
+ if path.Clean(name) == "/" {
+ return inaccessibleChildDir{File: file}, nil
+ }
+
+ return file, nil
+}
+
+func (fs inaccessibleChildFs) Stat(name string) (os.FileInfo, error) {
+ if path.Clean(name) == fs.child {
+ return nil, os.ErrPermission
+ }
+
+ return fs.Fs.Stat(name)
+}
+
+func (fs inaccessibleChildFs) LstatIfPossible(name string) (os.FileInfo, bool, error) {
+ if path.Clean(name) == fs.child {
+ return nil, false, os.ErrPermission
+ }
+
+ if lstater, ok := fs.Fs.(afero.Lstater); ok {
+ return lstater.LstatIfPossible(name)
+ }
+
+ info, err := fs.Fs.Stat(name)
+ return info, false, err
+}
+
+type inaccessibleChildDir struct {
+ afero.File
+}
+
+func (dir inaccessibleChildDir) Readdir(int) ([]os.FileInfo, error) {
+ return nil, os.ErrPermission
+}
+
+func TestReadListingSkipsInaccessibleChildren(t *testing.T) {
+ memFs := afero.NewMemMapFs()
+ for _, dir := range []string{"/media", "/proton-mount"} {
+ if err := memFs.Mkdir(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ file, err := NewFileInfo(&FileOptions{
+ Fs: inaccessibleChildFs{Fs: memFs, child: "/proton-mount"},
+ Path: "/",
+ Expand: true,
+ Checker: allowAllChecker{},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if file.Listing == nil {
+ t.Fatal("expected root listing")
+ }
+
+ if got := len(file.Items); got != 1 {
+ t.Fatalf("expected one accessible child, got %d", got)
+ }
+
+ if got := file.Items[0].Name; got != "media" {
+ t.Fatalf("expected accessible child to be listed, got %q", got)
+ }
+
+ if got := file.NumDirs; got != 1 {
+ t.Fatalf("expected one listed directory, got %d", got)
+ }
+}
+
+func TestFileInfoRealPathUsesScopedFsRealPath(t *testing.T) {
+ root := t.TempDir()
+ file := &FileInfo{
+ Fs: NewScopedFs(afero.NewOsFs(), root),
+ 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/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
new file mode 100644
index 00000000..97ce32d0
--- /dev/null
+++ b/files/scoped.go
@@ -0,0 +1,253 @@
+package files
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/afero"
+)
+
+// ScopedFs is an afero.Fs that confines every operation to a base directory and
+// refuses to follow a symbolic link whose on-disk target resolves outside that
+// base. It wraps an *afero.BasePathFs — which already provides the lexical
+// confinement — and adds a per-operation scope check on every call that would
+// dereference a symlink at the OS layer (open, stat, lstat, chmod, …).
+type ScopedFs struct {
+ base *afero.BasePathFs
+}
+
+var (
+ _ afero.Fs = (*ScopedFs)(nil)
+ _ 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
+ }
+ return &ScopedFs{base: afero.NewBasePathFs(source, path).(*afero.BasePathFs)}
+}
+
+// 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
+// filesystem path (e.g. disk.UsageWithContext).
+func (s *ScopedFs) RealPath(name string) (string, error) {
+ return s.base.RealPath(name)
+}
+
+// guard returns an error if name's on-disk target resolves outside the scope.
+func (s *ScopedFs) guard(name string) error {
+ ok, err := s.within(name)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return os.ErrPermission
+ }
+ return nil
+}
+
+// within reports whether the on-disk target of p — after resolving any symbolic
+// links — stays within the scoped root. It exists to stop a symlink that lives
+// lexically inside the scope but points outside it from being followed for
+// reads, writes, or shares.
+//
+// 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. 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 {
+ return false, err
+ }
+
+ target := afero.FullBaseFsPath(s.base, p)
+ resolved, err := filepath.EvalSymlinks(target)
+ // 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
+ }
+ resolved, err = filepath.EvalSymlinks(target)
+ }
+ if err != nil {
+ return false, err
+ }
+
+ // Compare against root with a trailing separator so a sibling like
+ // "/srvother" is not treated as being inside "/srv". When root is itself the
+ // filesystem boundary (e.g. "/"), it already ends in a separator, so avoid
+ // producing "//" — which no path would match — and accept any path under it.
+ prefix := root
+ if !strings.HasSuffix(prefix, string(filepath.Separator)) {
+ prefix += string(filepath.Separator)
+ }
+
+ return resolved == root || strings.HasPrefix(resolved, prefix), nil
+}
+
+func (s *ScopedFs) Create(name string) (afero.File, error) {
+ if err := s.guard(name); err != nil {
+ return nil, err
+ }
+ return s.base.Create(name)
+}
+
+func (s *ScopedFs) Mkdir(name string, perm os.FileMode) error {
+ if err := s.guard(name); err != nil {
+ return err
+ }
+ return s.base.Mkdir(name, perm)
+}
+
+func (s *ScopedFs) MkdirAll(path string, perm os.FileMode) error {
+ if err := s.guard(path); err != nil {
+ return err
+ }
+ return s.base.MkdirAll(path, perm)
+}
+
+func (s *ScopedFs) Open(name string) (afero.File, error) {
+ if err := s.guard(name); err != nil {
+ return nil, err
+ }
+ return s.base.Open(name)
+}
+
+func (s *ScopedFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
+ if err := s.guard(name); err != nil {
+ return nil, err
+ }
+ return s.base.OpenFile(name, flag, perm)
+}
+
+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)
+}
+
+func (s *ScopedFs) Rename(oldname, newname string) error {
+ if err := s.guard(oldname); err != nil {
+ return err
+ }
+ if err := s.guard(newname); err != nil {
+ return err
+ }
+ return s.base.Rename(oldname, newname)
+}
+
+func (s *ScopedFs) Stat(name string) (os.FileInfo, error) {
+ if err := s.guard(name); err != nil {
+ return nil, err
+ }
+ return s.base.Stat(name)
+}
+
+func (s *ScopedFs) Name() string { return "ScopedFs" }
+
+func (s *ScopedFs) Chmod(name string, mode os.FileMode) error {
+ if err := s.guard(name); err != nil {
+ return err
+ }
+ return s.base.Chmod(name, mode)
+}
+
+func (s *ScopedFs) Chown(name string, uid, gid int) error {
+ if err := s.guard(name); err != nil {
+ return err
+ }
+ return s.base.Chown(name, uid, gid)
+}
+
+func (s *ScopedFs) Chtimes(name string, atime, mtime time.Time) error {
+ if err := s.guard(name); err != nil {
+ return err
+ }
+ return s.base.Chtimes(name, atime, mtime)
+}
+
+func (s *ScopedFs) LstatIfPossible(name string) (os.FileInfo, bool, error) {
+ if err := s.guard(name); err != nil {
+ return nil, false, err
+ }
+ return s.base.LstatIfPossible(name)
+}
diff --git a/fileutils/copy_test.go b/fileutils/copy_test.go
new file mode 100644
index 00000000..6899e032
--- /dev/null
+++ b/fileutils/copy_test.go
@@ -0,0 +1,124 @@
+package fileutils
+
+import (
+ "os"
+ "path"
+ "path/filepath"
+ "testing"
+
+ "github.com/filebrowser/filebrowser/v2/files"
+ "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
+// recursive copy path (GHSA-c2gv-wf5f-hjhh, an incomplete fix of
+// GHSA-239w-m3h6-ch8v).
+func TestCopyDoesNotDereferenceEscapingSymlink(t *testing.T) {
+ base := t.TempDir()
+ scope := filepath.Join(base, "scope")
+ if err := os.MkdirAll(filepath.Join(scope, "srcdir"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // A secret living outside the scope.
+ secret := filepath.Join(base, "secret.txt")
+ if err := os.WriteFile(secret, []byte("OUT-OF-SCOPE-SECRET"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // An escaping symlink planted inside the user's scope.
+ if err := os.Symlink(secret, filepath.Join(scope, "srcdir", "link.txt")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+
+ afs := files.NewScopedFs(afero.NewOsFs(), scope)
+
+ err := Copy(afs, "/srcdir", "/dstdir", 0o644, 0o755)
+ if err == nil {
+ t.Fatal("expected copy of a directory containing an escaping symlink to fail")
+ }
+
+ // The escaping symlink's target content must not have landed in scope.
+ if data, readErr := afero.ReadFile(afs, "/dstdir/link.txt"); readErr == nil {
+ t.Fatalf("escaping symlink was dereferenced into scope: got %q", string(data))
+ }
+}
+
+// A symlink whose target stays within scope is legitimate and must still be
+// copied (dereferenced) so the fix does not over-block normal usage.
+func TestCopyAllowsInScopeSymlink(t *testing.T) {
+ scope := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(scope, "srcdir", "real"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(scope, "srcdir", "real", "f.txt"), []byte("in-scope"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(scope, "srcdir", "real", "f.txt"), filepath.Join(scope, "srcdir", "link.txt")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+
+ afs := files.NewScopedFs(afero.NewOsFs(), scope)
+
+ if err := Copy(afs, "/srcdir", "/dstdir", 0o644, 0o755); err != nil {
+ t.Fatalf("expected copy of an in-scope symlink to succeed, got: %v", err)
+ }
+
+ data, err := afero.ReadFile(afs, "/dstdir/link.txt")
+ if err != nil {
+ t.Fatalf("expected in-scope symlink to be copied, got: %v", err)
+ }
+ if string(data) != "in-scope" {
+ t.Fatalf("unexpected copied content: %q", string(data))
+ }
+}
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
diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue
index d6747f59..33b5a993 100644
--- a/frontend/src/components/files/ListingItem.vue
+++ b/frontend/src/components/files/ListingItem.vue
@@ -179,6 +179,7 @@ const drop = async (event: Event) => {
to: props.url + encodeURIComponent(fileStore.req?.items[i].name),
name: fileStore.req?.items[i].name,
size: fileStore.req?.items[i].size,
+ isDir: fileStore.req?.items[i].isDir,
modified: fileStore.req?.items[i].modified,
overwrite: false,
rename: false,
@@ -204,7 +205,7 @@ const drop = async (event: Event) => {
.catch($showError);
};
- const conflict = await upload.checkConflict(items, path);
+ const conflict = await upload.checkConflict(items, path, true);
if (conflict.length > 0) {
layoutStore.showHover({
diff --git a/frontend/src/components/prompts/Copy.vue b/frontend/src/components/prompts/Copy.vue
index a24f0fed..37139b9b 100644
--- a/frontend/src/components/prompts/Copy.vue
+++ b/frontend/src/components/prompts/Copy.vue
@@ -92,6 +92,7 @@ export default {
to: this.dest + encodeURIComponent(this.req.items[item].name),
name: this.req.items[item].name,
size: this.req.items[item].size,
+ isDir: this.req.items[item].isDir,
modified: this.req.items[item].modified,
overwrite: false,
rename: this.$route.path === this.dest,
@@ -122,7 +123,7 @@ export default {
});
};
- const conflict = upload.checkConflict(items, this.dest);
+ const conflict = await upload.checkConflict(items, this.dest, true);
if (conflict.length > 0) {
this.showHover({
diff --git a/frontend/src/components/prompts/Move.vue b/frontend/src/components/prompts/Move.vue
index 6970e0eb..b8086a67 100644
--- a/frontend/src/components/prompts/Move.vue
+++ b/frontend/src/components/prompts/Move.vue
@@ -98,6 +98,7 @@ export default {
to: this.dest + encodeURIComponent(this.req.items[item].name),
name: this.req.items[item].name,
size: this.req.items[item].size,
+ isDir: this.req.items[item].isDir,
modified: this.req.items[item].modified,
overwrite: false,
rename: false,
@@ -122,7 +123,7 @@ export default {
});
};
- const conflict = upload.checkConflict(items, this.dest);
+ const conflict = await upload.checkConflict(items, this.dest, true);
if (conflict.length > 0) {
this.showHover({
diff --git a/frontend/src/components/prompts/Share.vue b/frontend/src/components/prompts/Share.vue
index 282902fb..c51904d1 100644
--- a/frontend/src/components/prompts/Share.vue
+++ b/frontend/src/components/prompts/Share.vue
@@ -38,7 +38,7 @@
class="action"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')"
- :disabled="!!link.password_hash"
+ :disabled="!!link.hasPassword"
@click="copyToClipboard(buildDownloadLink(link))"
>
content_paste_go
diff --git a/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts b/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts
new file mode 100644
index 00000000..710baf0d
--- /dev/null
+++ b/frontend/src/components/prompts/__tests__/copy-move-conflict.test.ts
@@ -0,0 +1,129 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import CopyPrompt from "@/components/prompts/Copy.vue";
+import MovePrompt from "@/components/prompts/Move.vue";
+import { files as api } from "@/api";
+import { checkConflict } from "@/utils/upload";
+
+vi.mock("@/api", () => ({
+ files: {
+ copy: vi.fn().mockResolvedValue(undefined),
+ move: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+vi.mock("@/api/utils", () => ({
+ removePrefix: (value: string) => value.replace(/^\/files/, ""),
+}));
+
+vi.mock("@/utils/buttons", () => ({
+ default: {
+ loading: vi.fn(),
+ success: vi.fn(),
+ done: vi.fn(),
+ },
+}));
+
+vi.mock("@/utils/upload", () => ({
+ checkConflict: vi.fn(),
+}));
+
+const conflict = [
+ {
+ index: 0,
+ name: "/target/file.txt",
+ origin: { size: 12 },
+ dest: { size: 10 },
+ checked: ["origin"],
+ isSmallerOnServer: true,
+ },
+];
+
+function makeContext() {
+ return {
+ selected: [0],
+ req: {
+ items: [
+ {
+ url: "/files/source/file.txt",
+ name: "file.txt",
+ size: 12,
+ isDir: false,
+ modified: "2026-06-04T00:00:00Z",
+ },
+ ],
+ },
+ dest: "/files/target/",
+ user: { redirectAfterCopyMove: false },
+ $route: { path: "/files/source/" },
+ $router: { push: vi.fn() },
+ reload: false,
+ preselect: "",
+ showHover: vi.fn(),
+ closeHovers: vi.fn(),
+ $showError: vi.fn(),
+ };
+}
+
+const event = {
+ preventDefault: vi.fn(),
+};
+
+describe("copy and move conflict prompts", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(checkConflict).mockResolvedValue(
+ conflict as ConflictingResource[]
+ );
+ });
+
+ it("waits for copy conflict detection before calling the copy API", async () => {
+ const context = makeContext();
+
+ await (CopyPrompt as any).methods.copy.call(context, event);
+
+ expect(checkConflict).toHaveBeenCalledWith(
+ [
+ expect.objectContaining({
+ to: "/files/target/file.txt",
+ isDir: false,
+ }),
+ ],
+ "/files/target/",
+ true
+ );
+ expect(context.showHover).toHaveBeenCalledWith(
+ expect.objectContaining({
+ prompt: "resolve-conflict",
+ props: { conflict },
+ })
+ );
+ expect(api.copy).not.toHaveBeenCalled();
+ });
+
+ it("waits for move conflict detection before calling the move API", async () => {
+ const context = makeContext();
+
+ await (MovePrompt as any).methods.move.call(context, event);
+
+ expect(checkConflict).toHaveBeenCalledWith(
+ [
+ expect.objectContaining({
+ to: "/files/target/file.txt",
+ isDir: false,
+ }),
+ ],
+ "/files/target/",
+ true
+ );
+ expect(context.showHover).toHaveBeenCalledWith(
+ expect.objectContaining({
+ prompt: "resolve-conflict",
+ props: expect.objectContaining({
+ conflict,
+ files: expect.any(Array),
+ }),
+ })
+ );
+ expect(api.move).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/css/__tests__/mobile.test.ts b/frontend/src/css/__tests__/mobile.test.ts
new file mode 100644
index 00000000..8b492c55
--- /dev/null
+++ b/frontend/src/css/__tests__/mobile.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+const mobileCss = readFileSync(resolve(__dirname, "../mobile.css"), "utf8");
+
+const normalizedCss = mobileCss.replace(/\s+/g, " ");
+
+describe("mobile file listing styles", () => {
+ it("hides file row metadata without hiding list header sort controls", () => {
+ expect(normalizedCss).toContain(
+ "#listing.list .item:not(.header) .size { display: none;"
+ );
+ expect(normalizedCss).toContain(
+ "#listing.list .item:not(.header) .modified { display: none;"
+ );
+
+ expect(normalizedCss).not.toMatch(
+ /#listing\.list \.item \.(size|modified) \{ display: none;/
+ );
+ });
+});
diff --git a/frontend/src/css/epubReader.css b/frontend/src/css/epubReader.css
index a575fb27..c8ceb110 100644
--- a/frontend/src/css/epubReader.css
+++ b/frontend/src/css/epubReader.css
@@ -29,6 +29,13 @@
.epub-reader .tocButton {
color: var(--text);
+ /*
+ * vue-reader positions the TOC toggle at top: 10px, which lands under the
+ * fixed 4em header bar (see .header) — so clicks hit the header's Close
+ * button instead of opening the table of contents. Push it just below the
+ * header, tracking the header's height so it stays clear if that changes.
+ */
+ top: calc(4em + 10px);
}
.epub-reader .tocButton.tocButtonExpanded {
diff --git a/frontend/src/css/mobile.css b/frontend/src/css/mobile.css
index f8b2a45b..baace203 100644
--- a/frontend/src/css/mobile.css
+++ b/frontend/src/css/mobile.css
@@ -14,12 +14,27 @@
body {
padding-bottom: 5em;
}
- #listing.list .item .size {
+ #listing.list .item:not(.header) .size {
display: none;
}
- #listing.list .item .name {
+ #listing.list .item:not(.header) .name {
width: 60%;
}
+ #listing.list .item.header .name {
+ margin-right: 0;
+ width: 45%;
+ }
+ #listing.list .item.header .size {
+ width: 25%;
+ }
+ #listing.list .item.header .modified {
+ width: 30%;
+ }
+ #listing.list .item.header p {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
#more {
display: inherit;
}
@@ -162,10 +177,22 @@
}
@media (max-width: 450px) {
- #listing.list .item .modified {
+ #listing.list .item:not(.header) .modified {
display: none;
}
- #listing.list .item .name {
+ #listing.list .item:not(.header) .name {
width: 100%;
}
+ #listing.list .item.header {
+ padding: 0.75em 0.5em;
+ }
+ #listing.list .item.header .name {
+ width: 34%;
+ }
+ #listing.list .item.header .size {
+ width: 24%;
+ }
+ #listing.list .item.header .modified {
+ width: 42%;
+ }
}
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": {
diff --git a/frontend/src/i18n/ko.json b/frontend/src/i18n/ko.json
index 1accf5f9..fd4170d5 100644
--- a/frontend/src/i18n/ko.json
+++ b/frontend/src/i18n/ko.json
@@ -29,8 +29,8 @@
"rename": "이름 바꾸기",
"replace": "대체",
"reportIssue": "문제 보고",
- "resumeTransfer": "Resume previous transfer",
- "resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
+ "resumeTransfer": "이전 전송을 재개",
+ "resumeTransferTooltip": "서버에 있는 파일 중 전송이 중단되었을 가능성이 있는 작은 파일을 제외하고 충돌하는 모든 파일을 건너뜁니다.",
"save": "저장",
"schedule": "일정",
"search": "검색",
@@ -283,7 +283,7 @@
"currentPassword": "현재 비밀번호"
},
"sidebar": {
- "diskUsed": "{used} of {total} used",
+ "diskUsed": "{total} 중 {used} 사용",
"help": "도움말",
"hugoNew": "Hugo New",
"login": "로그인",
diff --git a/frontend/src/i18n/pt-pt.json b/frontend/src/i18n/pt-pt.json
index 2c04e8aa..721a759b 100644
--- a/frontend/src/i18n/pt-pt.json
+++ b/frontend/src/i18n/pt-pt.json
@@ -238,7 +238,7 @@
"newPasswordConfirm": "Confirmar a sua palavra-passe",
"newUser": "Novo Utilizador",
"password": "Palavra-passe",
- "passwordUpdated": "Palavras-passe não coincidem!",
+ "passwordUpdated": "Palavra-passe atualizada!",
"path": "Caminho",
"perm": {
"create": "Criar ficheiros e pastas",
diff --git a/frontend/src/i18n/pt.json b/frontend/src/i18n/pt.json
deleted file mode 100644
index d0523251..00000000
--- a/frontend/src/i18n/pt.json
+++ /dev/null
@@ -1,309 +0,0 @@
-{
- "buttons": {
- "cancel": "Cancelar",
- "clear": "Limpar",
- "close": "Fechar",
- "continue": "Continuar",
- "copy": "Copiar",
- "copyFile": "Copiar ficheiro",
- "copyToClipboard": "Copiar",
- "copyDownloadLinkToClipboard": "Copy download link to clipboard",
- "create": "Criar",
- "delete": "Eliminar",
- "download": "Descarregar",
- "file": "File",
- "folder": "Folder",
- "fullScreen": "Toggle full screen",
- "hideDotfiles": "Hide dotfiles",
- "info": "Info",
- "more": "Mais",
- "move": "Mover",
- "moveFile": "Mover ficheiro",
- "new": "Novo",
- "next": "Próximo",
- "ok": "OK",
- "permalink": "Obter link permanente",
- "previous": "Anterior",
- "preview": "Preview",
- "publish": "Publicar",
- "rename": "Alterar nome",
- "replace": "Substituir",
- "reportIssue": "Reportar erro",
- "resumeTransfer": "Resume previous transfer",
- "resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
- "save": "Guardar",
- "schedule": "Agendar",
- "search": "Pesquisar",
- "select": "Selecionar",
- "selectMultiple": "Selecionar vários",
- "share": "Partilhar",
- "shell": "Alternar shell",
- "submit": "Submit",
- "switchView": "Alterar vista",
- "toggleSidebar": "Alternar barra lateral",
- "update": "Atualizar",
- "upload": "Enviar",
- "openFile": "Open file",
- "openDirect": "View raw",
- "discardChanges": "Discard",
- "stopSearch": "Stop searching",
- "saveChanges": "Save changes",
- "editAsText": "Edit as Text",
- "increaseFontSize": "Increase font size",
- "decreaseFontSize": "Decrease font size",
- "overrideAll": "Replace all files in destination folder",
- "skipAll": "Skip all conflicting files",
- "renameAll": "Rename all files (create a copy)",
- "singleDecision": "Decide for each conflicting file"
- },
- "download": {
- "downloadFile": "Descarregar ficheiro",
- "downloadFolder": "Descarregar pasta",
- "downloadSelected": "Download Selected"
- },
- "upload": {
- "abortUpload": "Are you sure you wish to abort?"
- },
- "errors": {
- "forbidden": "Não tem permissões para aceder a isto",
- "internal": "Algo correu bastante mal.",
- "notFound": "Esta localização não é alcançável.",
- "connection": "The server can't be reached."
- },
- "files": {
- "body": "Corpo",
- "closePreview": "Fechar pré-visualização",
- "files": "Ficheiros",
- "folders": "Pastas",
- "home": "Início",
- "lastModified": "Última alteração",
- "loading": "A carregar...",
- "lonely": "Sinto-me sozinho...",
- "metadata": "Metadados",
- "multipleSelectionEnabled": "Seleção múltipla ativada",
- "name": "Nome",
- "size": "Tamanho",
- "sortByLastModified": "Ordenar pela última alteração",
- "sortByName": "Ordenar pelo nome",
- "sortBySize": "Ordenar pelo tamanho",
- "noPreview": "Preview is not available for this file.",
- "csvTooLarge": "CSV file is too large for preview (>5MB). Please download to view.",
- "csvLoadFailed": "Failed to load CSV file.",
- "showingRows": "Showing {count} row(s)",
- "columnSeparator": "Column Separator",
- "csvSeparators": {
- "comma": "Comma (,)",
- "semicolon": "Semicolon (;)",
- "both": "Both (,) and (;)"
- },
- "fileEncoding": "File Encoding"
- },
- "help": {
- "click": "selecionar pasta ou ficheiro",
- "ctrl": {
- "click": "selecionar várias pastas e ficheiros",
- "f": "pesquisar",
- "s": "guardar um ficheiro ou descarrega a pasta em que está a navegar"
- },
- "del": "eliminar os ficheiros selecionados",
- "doubleClick": "abrir pasta ou ficheiro",
- "esc": "limpar seleção e/ou fechar menu",
- "f1": "esta informação",
- "f2": "alterar nome do ficheiro",
- "help": "Ajuda"
- },
- "login": {
- "createAnAccount": "Criar uma conta",
- "loginInstead": "Já tenho uma conta",
- "password": "Palavra-passe",
- "passwordConfirm": "Confirmação da palavra-passe",
- "passwordsDontMatch": "As palavras-passe não coincidem",
- "signup": "Registar",
- "submit": "Entrar na conta",
- "username": "Nome de utilizador",
- "usernameTaken": "O nome de utilizador já está registado",
- "wrongCredentials": "Dados errados",
- "passwordTooShort": "Password must be at least {min} characters",
- "logout_reasons": {
- "inactivity": "You have been logged out due to inactivity."
- }
- },
- "permanent": "Permanente",
- "prompts": {
- "copy": "Copiar",
- "copyMessage": "Escolha um lugar para onde copiar os ficheiros:",
- "currentlyNavigating": "A navegar em:",
- "deleteMessageMultiple": "Quer eliminar {count} ficheiro(s)?",
- "deleteMessageSingle": "Quer eliminar esta pasta/ficheiro?",
- "deleteMessageShare": "Are you sure you wish to delete this share({path})?",
- "deleteUser": "Are you sure you want to delete this user?",
- "deleteTitle": "Eliminar ficheiros",
- "displayName": "Nome:",
- "download": "Descarregar ficheiros",
- "downloadMessage": "Escolha o formato do ficheiro que quer descarregar.",
- "error": "Algo correu mal",
- "fileInfo": "Informação do ficheiro",
- "filesSelected": "{count} ficheiros selecionados.",
- "lastModified": "Última alteração",
- "move": "Mover",
- "moveMessage": "Escolha uma nova casa para os seus ficheiros/pastas:",
- "newArchetype": "Criar um novo post baseado num \"archetype\". O seu ficheiro será criado na pasta \"content\".",
- "newDir": "Nova pasta",
- "newDirMessage": "Escreva o nome da nova pasta.",
- "newFile": "Novo ficheiro",
- "newFileMessage": "Escreva o nome do novo ficheiro.",
- "numberDirs": "Número de pastas",
- "numberFiles": "Número de ficheiros",
- "rename": "Alterar nome",
- "renameMessage": "Insira um novo nome para",
- "replace": "Substituir",
- "replaceMessage": "Já existe um ficheiro com nome igual a um dos que está a tentar enviar. Quer substituí-lo?\n",
- "schedule": "Agendar",
- "scheduleMessage": "Escolha uma data para publicar este post.",
- "show": "Mostrar",
- "size": "Tamanho",
- "upload": "Upload",
- "uploadFiles": "Uploading {files} files...",
- "uploadMessage": "Select an option to upload.",
- "optionalPassword": "Optional password",
- "resolution": "Resolution",
- "discardEditorChanges": "Are you sure you wish to discard the changes you've made?",
- "replaceOrSkip": "Replace or skip files",
- "resolveConflict": "Which files do you want to keep?",
- "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.",
- "fastConflictResolve": "The destination folder there are {count} files with same name.",
- "uploadingFiles": "Uploading files",
- "filesInOrigin": "Files in origin",
- "filesInDest": "Files in destination",
- "override": "Overwrite",
- "skip": "Skip",
- "forbiddenError": "Forbidden Error",
- "currentPassword": "Your password",
- "currentPasswordMessage": "Enter your password to validate this action."
- },
- "search": {
- "images": "Imagens",
- "music": "Música",
- "pdf": "PDF",
- "pressToSearch": "Tecla Enter para pesquisar...",
- "search": "Pesquisar...",
- "typeToSearch": "Escrever para pesquisar...",
- "types": "Tipos",
- "video": "Vídeos"
- },
- "settings": {
- "aceEditorTheme": "Ace editor theme",
- "admin": "Admin",
- "administrator": "Administrador",
- "allowCommands": "Executar comandos",
- "allowEdit": "Editar, renomear e eliminar ficheiros ou pastas",
- "allowNew": "Criar novos ficheiros e pastas",
- "allowPublish": "Publicar novas páginas e conteúdos",
- "allowSignup": "Permitir que os utilizadores criem contas",
- "hideLoginButton": "Hide the login button from public pages",
- "avoidChanges": "(deixe em branco para manter)",
- "branding": "Marca",
- "brandingDirectoryPath": "Caminho da pasta de marca",
- "brandingHelp": "Pode personalizar a aparência do seu Navegador de Ficheiros, alterar o nome, substituindo o logótipo, adicionando estilos personalizados e mesmo desativando links externos para o GitHub.\nPara mais informações sobre marca personalizada, por favor veja {0}.",
- "changePassword": "Alterar palavra-passe",
- "commandRunner": "Execução de comandos",
- "commandRunnerHelp": "Aqui pode definir comandos que são executados nos eventos nomeados. Tem de escrever um por linha. As variáveis de ambiente {0} e {1} estarão disponíveis, sendo {0} relativo a {1}. Para mais informações sobre esta funcionalidade e as variáveis de ambiente, veja {2}.",
- "commandsUpdated": "Comandos atualizados!",
- "createUserDir": "Criar automaticamente a pasta de início ao adicionar um novo utilizador",
- "minimumPasswordLength": "Minimum password length",
- "tusUploads": "Chunked Uploads",
- "tusUploadsHelp": "File Browser supports chunked file uploads, allowing for the creation of efficient, reliable, resumable and chunked file uploads even on unreliable networks.",
- "tusUploadsChunkSize": "Indicates to maximum size of a request (direct uploads will be used for smaller uploads). You may input a plain integer denoting byte size input or a string like 10MB, 1GB etc.",
- "tusUploadsRetryCount": "Number of retries to perform if a chunk fails to upload.",
- "userHomeBasePath": "Base path for user home directories",
- "userScopeGenerationPlaceholder": "The scope will be auto generated",
- "createUserHomeDirectory": "Create user home directory",
- "customStylesheet": "Folha de estilos personalizada",
- "defaultUserDescription": "Estas são as configurações padrão para novos utilizadores.",
- "disableExternalLinks": "Desativar links externos (exceto documentação)",
- "disableUsedDiskPercentage": "Disable used disk percentage graph",
- "documentation": "documentação",
- "examples": "Exemplos",
- "executeOnShell": "Executar na shell",
- "executeOnShellDescription": "Por padrão, o Navegador de Ficheiros executa os comandos chamando os seus binários diretamente. Se em vez disso, quiser executá-los numa shell (como Bash ou PowerShell), pode definir isso aqui com os argumentos e bandeiras necessários. Se definido, o comando que executa será anexado como um argumento. Isto aplica-se tanto a comandos do utilizador como a hooks de eventos.",
- "globalRules": "Isto é um conjunto global de regras de permissão e negação. Elas aplicam-se a todos os utilizadores. Pode especificar regras específicas para cada configuração do utilizador para sobreporem-se a estas.",
- "globalSettings": "Configurações globais",
- "hideDotfiles": "Hide dotfiles",
- "insertPath": "Inserir o caminho",
- "insertRegex": "Inserir expressão regular",
- "instanceName": "Nome da instância",
- "language": "Linguagem",
- "lockPassword": "Não permitir que o utilizador altere a palavra-passe",
- "newPassword": "Nova palavra-passe",
- "newPasswordConfirm": "Confirme a nova palavra-passe",
- "newUser": "Novo utilizador",
- "password": "Palavra-passe",
- "passwordUpdated": "Palavra-passe atualizada!",
- "path": "Path",
- "perm": {
- "create": "Criar ficheiros e pastas",
- "delete": "Eliminar ficheiros e pastas",
- "download": "Descarregar",
- "execute": "Executar comandos",
- "modify": "Editar ficheiros",
- "rename": "Alterar o nome ou mover ficheiros e pastas",
- "share": "Share files (require download permission)"
- },
- "permissions": "Permissões",
- "permissionsHelp": "Pode definir o utilizador como administrador ou escolher as permissões manualmente. Se selecionar a opção \"Administrador\", todas as outras opções serão automaticamente selecionadas. A gestão dos utilizadores é um privilégio restringido aos administradores.\n",
- "profileSettings": "Configurações do utilizador",
- "redirectAfterCopyMove": "Redirect to destination after copy/move",
- "ruleExample1": "previne o acesso a qualquer \"dotfile\" (como .git, .gitignore) em qualquer pasta\n",
- "ruleExample2": "bloqueia o acesso ao ficheiro chamado Caddyfile na raiz.",
- "rules": "Regras",
- "rulesHelp": "Aqui pode definir um conjunto de regras para permitir ou bloquear o acesso do utilizador a determinados ficheiros ou pastas. Os ficheiros bloqueados não irão aparecer durante a navegação. Suportamos expressões regulares e os caminhos dos ficheiros devem ser relativos à base do utilizador.\n",
- "scope": "Base",
- "setDateFormat": "Set exact date format",
- "settingsUpdated": "Configurações atualizadas!",
- "shareDuration": "Share Duration",
- "shareManagement": "Share Management",
- "shareDeleted": "Share deleted!",
- "singleClick": "Use single clicks to open files and directories",
- "themes": {
- "default": "System default",
- "dark": "Dark",
- "light": "Light",
- "title": "Theme"
- },
- "user": "Utilizador",
- "userCommands": "Comandos",
- "userCommandsHelp": "Uma lista, separada com espaços, de comandos disponíveis para este utilizados. Exemplo:",
- "userCreated": "Utilizador criado!",
- "userDefaults": "Configurações padrão do utilizador",
- "userDeleted": "Utilizador eliminado!",
- "userManagement": "Gestão de utilizadores",
- "userUpdated": "Utilizador atualizado!",
- "username": "Nome de utilizador",
- "users": "Utilizadores",
- "currentPassword": "Your Current Password"
- },
- "sidebar": {
- "diskUsed": "{used} of {total} used",
- "help": "Ajuda",
- "hugoNew": "Hugo New",
- "login": "Entrar",
- "logout": "Sair",
- "myFiles": "Meus ficheiros",
- "newFile": "Novo ficheiro",
- "newFolder": "Nova pasta",
- "preview": "Pré-visualizar",
- "settings": "Configurações",
- "signup": "Registar",
- "siteSettings": "Configurações do site"
- },
- "success": {
- "linkCopied": "Link copiado!"
- },
- "time": {
- "days": "Dias",
- "hours": "Horas",
- "minutes": "Minutos",
- "seconds": "Segundos",
- "unit": "Unidades de tempo"
- }
-}
diff --git a/frontend/src/i18n/zh-cn.json b/frontend/src/i18n/zh-cn.json
index 4d021be3..e03a6d98 100644
--- a/frontend/src/i18n/zh-cn.json
+++ b/frontend/src/i18n/zh-cn.json
@@ -28,104 +28,104 @@
"publish": "发布",
"rename": "重命名",
"replace": "替换",
- "reportIssue": "报告问题",
- "resumeTransfer": "Resume previous transfer",
- "resumeTransferTooltip": "Skip all conflicting files, except the ones that are smaller on the server as we suppose that their transfert has been interrupted.",
+ "reportIssue": "反馈问题",
+ "resumeTransfer": "恢复之前的传输任务",
+ "resumeTransferTooltip": "跳过所有同名冲突文件;服务器端文件更小则判定为传输中断,保留该文件并继续传输。",
"save": "保存",
"schedule": "计划",
"search": "搜索",
"select": "选择",
- "selectMultiple": "选择多个",
+ "selectMultiple": "多选",
"share": "分享",
- "shell": "激活 Shell",
+ "shell": "切换终端",
"submit": "提交",
- "switchView": "切换显示方式",
+ "switchView": "切换视图",
"toggleSidebar": "切换侧边栏",
"update": "更新",
"upload": "上传",
"openFile": "打开文件",
- "openDirect": "View raw",
- "discardChanges": "放弃更改",
+ "openDirect": "查看原始内容",
+ "discardChanges": "放弃",
"stopSearch": "停止搜索",
"saveChanges": "保存更改",
- "editAsText": "以文本形式编辑",
- "increaseFontSize": "增大字体大小",
- "decreaseFontSize": "减小字体大小",
- "overrideAll": "Replace all files in destination folder",
- "skipAll": "Skip all conflicting files",
- "renameAll": "Rename all files (create a copy)",
- "singleDecision": "Decide for each conflicting file"
+ "editAsText": "以文本模式编辑",
+ "increaseFontSize": "放大字体",
+ "decreaseFontSize": "缩小字体",
+ "overrideAll": "替换目标文件夹中的所有文件",
+ "skipAll": "跳过所有冲突文件",
+ "renameAll": "全部重命名(创建副本)",
+ "singleDecision": "逐个处理冲突文件"
},
"download": {
"downloadFile": "下载文件",
"downloadFolder": "下载文件夹",
- "downloadSelected": "下载已选"
+ "downloadSelected": "下载选中项"
},
"upload": {
"abortUpload": "你确定要中止吗?"
},
"errors": {
- "forbidden": "你无权限访问",
- "internal": "服务器出了点问题。",
- "notFound": "找不到文件。",
- "connection": "无法连接到服务器。"
+ "forbidden": "权限不足,拒绝访问",
+ "internal": "服务器内部错误",
+ "notFound": "该位置无法访问。",
+ "connection": "无法连接服务器"
},
"files": {
"body": "内容",
"closePreview": "关闭预览",
"files": "文件",
"folders": "文件夹",
- "home": "主页",
- "lastModified": "最后修改",
+ "home": "首页",
+ "lastModified": "修改时间",
"loading": "加载中...",
- "lonely": "这里没有任何文件...",
+ "lonely": "暂无任何文件...",
"metadata": "元数据",
- "multipleSelectionEnabled": "多选模式已开启",
+ "multipleSelectionEnabled": "已开启多选模式",
"name": "名称",
"size": "大小",
- "sortByLastModified": "按最后修改时间排序",
+ "sortByLastModified": "按修改时间排序",
"sortByName": "按名称排序",
"sortBySize": "按大小排序",
- "noPreview": "此文件无法预览。",
- "csvTooLarge": "CSV文件大到无法预览(>5MB)。请下载查看。",
- "csvLoadFailed": "加载 CSV 文件失败。",
- "showingRows": "正在显示 {count} 行",
+ "noPreview": "该文件暂不支持预览",
+ "csvTooLarge": "CSV 文件过大(>5MB),无法预览,请下载后查看",
+ "csvLoadFailed": "CSV 文件加载失败",
+ "showingRows": "当前显示 {count} 行数据",
"columnSeparator": "列分隔符",
"csvSeparators": {
"comma": "逗号 (,)",
"semicolon": "分号 (;)",
"both": "逗号 (,) 和分号 (;)"
},
- "fileEncoding": "File Encoding"
+ "fileEncoding": "文件编码"
},
"help": {
- "click": "选择文件或文件夹",
+ "click": "选中文件或文件夹",
"ctrl": {
- "click": "选择多个文件或文件夹",
+ "click": "多选文件或文件夹",
"f": "打开搜索框",
"s": "保存文件或下载当前文件夹"
},
"del": "删除所选的文件/文件夹",
"doubleClick": "打开文件/文件夹",
- "esc": "清除已选项或关闭提示信息",
- "f1": "显示该帮助信息",
- "f2": "重命名文件/文件夹",
+ "esc": "取消选中或关闭提示框",
+ "f1": "打开帮助",
+ "f2": "重命名文件",
"help": "帮助"
},
"login": {
- "createAnAccount": "创建用户",
- "loginInstead": "已有用户登录",
+ "createAnAccount": "注册账号",
+ "loginInstead": "已有账号",
"password": "密码",
"passwordConfirm": "确认密码",
- "passwordsDontMatch": "密码不一致",
+ "passwordsDontMatch": "两次输入的密码不一致",
"signup": "注册",
"submit": "登录",
"username": "用户名",
"usernameTaken": "用户名已经被使用",
"wrongCredentials": "用户名或密码错误",
- "passwordTooShort": "密码必须至少包含 {min} 个字符",
+ "passwordTooShort": "密码长度至少为 {min} 位字符",
"logout_reasons": {
- "inactivity": "由于未活动,您已登出。"
+ "inactivity": "因长时间未操作,系统已自动登出."
}
},
"permanent": "永久",
@@ -136,58 +136,58 @@
"deleteMessageMultiple": "你确定要删除这 {count} 个文件吗?",
"deleteMessageSingle": "你确定要删除这个文件/文件夹吗?",
"deleteMessageShare": "你确定要删除这个分享({path})吗?",
- "deleteUser": "你确定要删除这个用户吗?",
+ "deleteUser": "确定要删除该用户吗?",
"deleteTitle": "删除文件",
"displayName": "名称:",
"download": "下载文件",
- "downloadMessage": "请选择要下载的压缩格式。",
- "error": "出了一点问题...",
+ "downloadMessage": "请选择要下载的压缩包格式。",
+ "error": "出现未知错误...",
"fileInfo": "文件信息",
- "filesSelected": "已选择 {count} 个文件。",
- "lastModified": "最后修改",
+ "filesSelected": "已选中 {count} 个文件",
+ "lastModified": "修改时间",
"move": "移动",
"moveMessage": "请选择目标目录:",
- "newArchetype": "创建一个基于原型的新帖子。你的文件将会创建在内容文件夹中。",
+ "newArchetype": "基于模板新建文档,文件将创建在内容目录中。",
"newDir": "新建文件夹",
"newDirMessage": "请输入新文件夹的名称。",
"newFile": "新建文件",
"newFileMessage": "请输入新文件的名称。",
- "numberDirs": "文件夹数",
- "numberFiles": "文件数",
+ "numberDirs": "文件夹数量",
+ "numberFiles": "文件数量",
"rename": "重命名",
- "renameMessage": "请输入新名称,旧名称为:",
+ "renameMessage": "请输入新名称",
"replace": "替换",
- "replaceMessage": "你尝试上传的文件中有一个与现有文件的名称存在冲突。是否替换现有的同名文件?\n",
+ "replaceMessage": "你上传的文件与已有文件重名,是否跳过该文件继续上传,或是替换原有文件?\n",
"schedule": "计划",
- "scheduleMessage": "请选择发布这篇帖子的日期与时间。",
- "show": "点击以显示",
+ "scheduleMessage": "请选择文档发布的日期和时间",
+ "show": "显示",
"size": "大小",
"upload": "上传",
"uploadFiles": "正在上传 {files} ...",
- "uploadMessage": "选择上传选项。",
- "optionalPassword": "密码(选填,不填即无密码)",
+ "uploadMessage": "请选择上传选项",
+ "optionalPassword": "密码(选填,留空则无需密码)",
"resolution": "分辨率",
- "discardEditorChanges": "你确定要放弃所做的更改吗?",
- "replaceOrSkip": "Replace or skip files",
- "resolveConflict": "Which files do you want to keep?",
- "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.",
- "fastConflictResolve": "The destination folder there are {count} files with same name.",
- "uploadingFiles": "Uploading files",
- "filesInOrigin": "Files in origin",
- "filesInDest": "Files in destination",
- "override": "Overwrite",
- "skip": "Skip",
- "forbiddenError": "Forbidden Error",
- "currentPassword": "Your password",
- "currentPasswordMessage": "Enter your password to validate this action."
+ "discardEditorChanges": "是否放弃已修改的内容?",
+ "replaceOrSkip": "替换或跳过文件",
+ "resolveConflict": "保留哪些文件?",
+ "singleConflictResolve": "若同时保留两个版本,复制的文件会自动追加序号。",
+ "fastConflictResolve": "目标目录内存在 {count} 个同名文件",
+ "uploadingFiles": "正在上传文件",
+ "filesInOrigin": "源文件",
+ "filesInDest": "目标文件",
+ "override": "覆盖",
+ "skip": "跳过",
+ "forbiddenError": "权限错误",
+ "currentPassword": "当前密码",
+ "currentPasswordMessage": "请输入当前密码以验证操作"
},
"search": {
"images": "图像",
"music": "音乐",
"pdf": "PDF",
- "pressToSearch": "按回车以搜索...",
+ "pressToSearch": "按下回车开始搜索...",
"search": "搜索...",
- "typeToSearch": "输入以搜索...",
+ "typeToSearch": "输入内容进行搜索...",
"types": "类型",
"video": "视频"
},
@@ -195,47 +195,47 @@
"aceEditorTheme": "Ace编辑器主题",
"admin": "管理员",
"administrator": "管理员",
- "allowCommands": "执行命令(Shell 命令)",
+ "allowCommands": "允许执行终端命令",
"allowEdit": "编辑、重命名或删除文件/文件夹",
"allowNew": "创建新文件和文件夹",
"allowPublish": "发布新的帖子与页面",
"allowSignup": "允许用户注册",
"hideLoginButton": "从公开页面隐藏登录按钮",
- "avoidChanges": "(留空以避免更改)",
+ "avoidChanges": "(留空则不修改)",
"branding": "品牌",
- "brandingDirectoryPath": "品牌信息文件夹路径",
- "brandingHelp": "你可以通过改变实例名称,更换 Logo,加入自定义样式,甚至禁用到 Github 的外部链接来自定义 File Browser 的外观和感觉。\n想获得更多信息,请查看 {0}。",
+ "brandingDirectoryPath": "品牌资源目录路径",
+ "brandingHelp": "你可以修改站点名称、更换Logo、添加自定义样式,也可以禁用外部GitHub链接,来自定义本文件管理器界面。\n详情请查看 {0}。",
"changePassword": "更改密码",
"commandRunner": "命令执行器",
- "commandRunnerHelp": "你可以在此设置在下列事件中执行的命令。每行必须写一条命令。可以在命令中使用环境变量 {0} 和 {1},使 {0} 与 {1} 相关联。关于此功能和可用环境变量的更多信息,请阅读 {2}。",
+ "commandRunnerHelp": "在此设置事件触发命令,单行单条。环境变量 {0}、{1} 可用,{0} 相对 {1} 生效。功能与变量详情请参考 {2}。",
"commandsUpdated": "命令已更新!",
- "createUserDir": "在添加新用户的同时自动创建用户的主目录",
+ "createUserDir": "新增用户时自动创建用户主目录",
"minimumPasswordLength": "最小密码长度",
"tusUploads": "分块上传",
- "tusUploadsHelp": "File Browser 支持分块上传,在不佳的网络下也可进行高效、可靠、可续的文件上传",
- "tusUploadsChunkSize": "分块上传大小,例如 10MB 或 1GB",
+ "tusUploadsHelp": "支持文件分片上传,网络不佳时仍可稳定传输,并支持断点续传。",
+ "tusUploadsChunkSize": "分块大小(例如:10MB、1GB);小于该值的文件将直接上传。",
"tusUploadsRetryCount": "分块上传失败时的重试次数",
"userHomeBasePath": "用户主目录的路径",
"userScopeGenerationPlaceholder": "自动生成目录范围",
"createUserHomeDirectory": "创建用户主目录",
- "customStylesheet": "自定义样式表(CSS)",
- "defaultUserDescription": "这些是新用户的默认设置。",
+ "customStylesheet": "自定义样式表",
+ "defaultUserDescription": "以下为新用户的默认配置",
"disableExternalLinks": "禁止外部链接(帮助文档除外)",
"disableUsedDiskPercentage": "禁用磁盘已用空间展示",
"documentation": "帮助文档",
"examples": "示例",
- "executeOnShell": "在 Shell 中执行",
- "executeOnShellDescription": "默认情况下,File Browser 通过直接调用命令的二进制包来执行命令,如果想在 Shell中 执行(如 Bash 或 PowerShell),你可以在这里定义所使用的 Shell 和参数。设置后,你所执行的命令会作为参数追加。本设置对用户命令和事件钩子都生效。",
- "globalRules": "这是全局允许与禁止规则。它们作用于所有用户。你可以给每个用户定义单独的特殊规则来覆盖全局规则。",
+ "executeOnShell": "通过终端执行",
+ "executeOnShellDescription": "默认直接调用程序执行命令。如需通过终端运行(如Bash、PowerShell 等),可在此配置解释器、参数及选项,执行命令将自动作为参数传入。本设置对用户命令和事件钩子均生效。",
+ "globalRules": "全局访问规则,对所有用户生效。也可单独为用户设置规则来覆盖全局规则。",
"globalSettings": "全局设置",
"hideDotfiles": "不显示隐藏文件",
"insertPath": "插入路径",
"insertRegex": "插入正则表达式",
- "instanceName": "实例名称",
+ "instanceName": "站点名称",
"language": "语言",
"lockPassword": "禁止用户修改密码",
- "newPassword": "你的新密码",
- "newPasswordConfirm": "再次输入以确认你的新密码",
+ "newPassword": "新密码",
+ "newPasswordConfirm": "再次输入新密码",
"newUser": "新建用户",
"password": "密码",
"passwordUpdated": "密码已更新!",
@@ -247,23 +247,23 @@
"execute": "执行命令",
"modify": "编辑",
"rename": "重命名或移动文件和文件夹",
- "share": "Share files (require download permission)"
+ "share": "分享文件(需开启下载权限)"
},
"permissions": "权限",
"permissionsHelp": "你可以将该用户设置为管理员或单独选择各项权限。如果你选择了“管理员”,则其他的选项会被自动选中,同时该用户可以管理其他用户。\n",
"profileSettings": "个人设置",
"redirectAfterCopyMove": "复制/移动后转到目标位置",
- "ruleExample1": "阻止用户访问所有文件夹下任何以 . 开头的文件(隐藏文件, 例如: .git, .gitignore)。\n",
- "ruleExample2": "阻止用户访问其目录范围的根目录下名为 Caddyfile 的文件。",
+ "ruleExample1": "禁止用户访问所有目录下以 . 开头的隐藏文件(隐藏文件, 例如: .git, .gitignore)。\n",
+ "ruleExample2": "禁止用户访问个人根目录下的 Caddyfile 文件。",
"rules": "规则",
- "rulesHelp": "你可以为该用户制定一组黑名单或白名单式的规则,被屏蔽的文件将不会显示在列表中,用户也无权限访问,支持正则表达式和相对于用户范围的路径。\n",
+ "rulesHelp": "可为用户配置黑白名单规则,被限制的文件将隐藏且无法访问。支持正则表达式、相对用户目录的路径写法。\n",
"scope": "目录范围",
"setDateFormat": "显示精确的日期格式",
"settingsUpdated": "设置已更新!",
"shareDuration": "分享期限",
"shareManagement": "分享管理",
"shareDeleted": "分享已删除!",
- "singleClick": "使用单击来打开文件和文件夹",
+ "singleClick": "单击打开文件/文件夹",
"themes": {
"default": "系统默认",
"dark": "深色",
@@ -271,30 +271,30 @@
"title": "主题"
},
"user": "用户",
- "userCommands": "用户命令(Shell 命令)",
- "userCommandsHelp": "指定该用户可以执行的命令(Shell 命令),用空格分隔。例如:\n",
+ "userCommands": "可用终端命令",
+ "userCommandsHelp": "指定该用户可执行的终端命令,多个命令用空格分隔。示例:\n",
"userCreated": "用户已创建!",
"userDefaults": "用户默认设置",
"userDeleted": "用户已删除!",
"userManagement": "用户管理",
- "userUpdated": "用户已更新!",
+ "userUpdated": "用户信息已更新!",
"username": "用户名",
"users": "用户",
- "currentPassword": "您当前的密码"
+ "currentPassword": "当前密码"
},
"sidebar": {
- "diskUsed": "{used} of {total} used",
+ "diskUsed": "已使用 {used} / 总容量 {total}",
"help": "帮助",
"hugoNew": "Hugo 新建",
"login": "登录",
- "logout": "登出",
+ "logout": "退出",
"myFiles": "我的文件",
"newFile": "新建文件",
"newFolder": "新建文件夹",
"preview": "预览",
"settings": "设置",
"signup": "注册",
- "siteSettings": "网站设置"
+ "siteSettings": "站点设置"
},
"success": {
"linkCopied": "链接已复制!"
diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts
index c1592a21..f4864724 100644
--- a/frontend/src/types/api.d.ts
+++ b/frontend/src/types/api.d.ts
@@ -25,7 +25,7 @@ interface Share {
path: string;
expire?: any;
userID?: number;
- token?: string;
+ hasPassword?: boolean;
username?: string;
}
diff --git a/frontend/src/types/file.d.ts b/frontend/src/types/file.d.ts
index 7e6a9ebc..067598b0 100644
--- a/frontend/src/types/file.d.ts
+++ b/frontend/src/types/file.d.ts
@@ -53,6 +53,7 @@ interface ClipItem {
from: string;
name: string;
size?: number;
+ isDir?: boolean;
modified?: string;
}
diff --git a/frontend/src/utils/__tests__/check-conflict.test.ts b/frontend/src/utils/__tests__/check-conflict.test.ts
new file mode 100644
index 00000000..c60b33ab
--- /dev/null
+++ b/frontend/src/utils/__tests__/check-conflict.test.ts
@@ -0,0 +1,289 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { checkConflict } from "@/utils/upload";
+import { files as api } from "@/api";
+
+vi.mock("@/api", () => ({
+ files: {
+ fetch: vi.fn(),
+ fetchAll: vi.fn(),
+ },
+}));
+
+vi.mock("@/api/utils", () => ({
+ removePrefix: (value: string) => value.replace(/^\/files/, ""),
+}));
+
+// upload.ts imports these at module load; they reach window-bound constants
+// which don't exist in the node test environment. checkConflict never uses
+// them, so empty stubs keep the import graph from blowing up.
+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.
+function moveItem(name: string, dest: string, size = 12) {
+ return {
+ from: `/files/source/${encodeURIComponent(name)}`,
+ to: dest + encodeURIComponent(name),
+ name,
+ size,
+ isDir: false,
+ overwrite: false,
+ rename: false,
+ };
+}
+
+describe("checkConflict", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("detects a conflict for a plain filename", 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);
+ expect(conflicts[0].name).toBe("/target/file.txt");
+ });
+
+ // Regression for #5957: names with encodable characters (spaces, "#",
+ // non-ASCII) were keyed by the URL-encoded `to` value and never matched the
+ // server's raw path, so the conflict modal was skipped and the backend
+ // returned a bare 409 instead.
+ it.each(["my file.txt", "résumé.pdf", "a#b.txt"])(
+ "detects a conflict for %s (encodable characters)",
+ async (name) => {
+ vi.mocked(api.fetchAll).mockResolvedValue([
+ {
+ path: `/target/${name}`,
+ name,
+ size: 10,
+ modified: "2026-06-04T00:00:00Z",
+ isDir: false,
+ },
+ ]);
+
+ const conflicts = await checkConflict(
+ [moveItem(name, "/files/target/")],
+ "/files/target/"
+ );
+
+ expect(conflicts).toHaveLength(1);
+ expect(conflicts[0].name).toBe(`/target/${name}`);
+ }
+ );
+
+ it("reports no conflict when the destination has no matching name", async () => {
+ vi.mocked(api.fetchAll).mockResolvedValue([
+ {
+ path: "/target/other.txt",
+ name: "other.txt",
+ size: 10,
+ modified: "2026-06-04T00:00:00Z",
+ isDir: false,
+ },
+ ]);
+
+ const conflicts = await checkConflict(
+ [moveItem("my file.txt", "/files/target/")],
+ "/files/target/"
+ );
+
+ expect(conflicts).toHaveLength(0);
+ });
+
+ it("detects nested conflicts for folder uploads via fullPath", async () => {
+ vi.mocked(api.fetchAll).mockResolvedValue([
+ {
+ path: "/target/folder",
+ name: "folder",
+ size: 0,
+ modified: "2026-06-04T00:00:00Z",
+ isDir: true,
+ },
+ {
+ path: "/target/folder/nested file.txt",
+ name: "nested file.txt",
+ size: 10,
+ modified: "2026-06-04T00:00:00Z",
+ isDir: false,
+ },
+ ]);
+
+ const files = [
+ { name: "folder", size: 0, isDir: true, fullPath: "folder" },
+ {
+ name: "nested file.txt",
+ size: 12,
+ isDir: false,
+ fullPath: "folder/nested file.txt",
+ },
+ ];
+
+ const conflicts = await checkConflict(files, "/files/target/");
+
+ expect(conflicts).toHaveLength(1);
+ expect(conflicts[0].name).toBe("/target/folder/nested file.txt");
+ });
+
+ // The "upload folder" file input pushes only files (with a relative
+ // fullPath) and no directory entries. Conflict detection must still find a
+ // nested file even though its parent folder is not in the upload list.
+ it("detects nested conflicts when no directory entries are present", async () => {
+ vi.mocked(api.fetchAll).mockResolvedValue([
+ {
+ path: "/target/folder/deep/file.txt",
+ name: "file.txt",
+ size: 10,
+ modified: "2026-06-04T00:00:00Z",
+ isDir: false,
+ },
+ ]);
+
+ const files = [
+ {
+ name: "file.txt",
+ size: 12,
+ isDir: false,
+ fullPath: "folder/deep/file.txt",
+ },
+ ];
+
+ const conflicts = await checkConflict(files, "/files/target/");
+
+ expect(conflicts).toHaveLength(1);
+ expect(conflicts[0].name).toBe("/target/folder/deep/file.txt");
+ });
+
+ // 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("file.txt", "/files/target/"),
+ { ...moveItem("folder", "/files/target/", 0), isDir: true },
+ ];
+
+ const conflicts = await checkConflict(items, "/files/target/", true);
+
+ 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.
+ it("ignores a directory conflict for uploads (default)", async () => {
+ vi.mocked(api.fetchAll).mockResolvedValue([
+ {
+ path: "/target/folder",
+ name: "folder",
+ size: 0,
+ modified: "2026-06-04T00:00:00Z",
+ isDir: true,
+ },
+ ]);
+
+ const files = [
+ { name: "folder", size: 0, isDir: true, fullPath: "folder" },
+ ];
+
+ const conflicts = await checkConflict(files, "/files/target/");
+
+ expect(conflicts).toHaveLength(0);
+ });
+
+ it("returns no conflicts when the recursive listing fails", async () => {
+ vi.mocked(api.fetchAll).mockRejectedValue(new Error("404"));
+
+ const conflicts = await checkConflict(
+ [moveItem("file.txt", "/files/target/")],
+ "/files/target/"
+ );
+
+ 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 060f5d94..fec3c2a6 100644
--- a/frontend/src/utils/upload.ts
+++ b/frontend/src/utils/upload.ts
@@ -4,159 +4,120 @@ import url from "@/utils/url";
import { files as api } from "@/api";
import { removePrefix } from "@/api/utils";
-interface UploadEntryWithChild extends UploadEntry {
- children?: UploadEntryWithChild[];
- originalIndex: number;
-}
-
/**
- * Convert UploadList into a forest (array of root nodes).
- * This properly handles uploads with multiple top-level files/folders
- * instead of assuming a single root.
- * @param flatArray
- * @param basePath
+ * The path used to detect conflicts against the server's recursive listing.
+ *
+ * It MUST be the raw, un-encoded path relative to the destination:
+ * - `fullPath` is set for folder uploads and drag & drop (e.g. "sub/file.txt").
+ * - `name` is the leaf for every other case (copy/move/paste/single upload),
+ * which is always a flat top-level entry.
+ *
+ * We never key on `item.to`: it is URL-encoded (`dest + encodeURIComponent(name)`)
+ * and would miss conflicts for any name with encodable characters (spaces, "#",
+ * non-ASCII, ...), surfacing a raw 409 error instead of the conflict modal.
+ * @param item
*/
-function flatToForest(
- flatArray: UploadList,
- basePath: string
-): UploadEntryWithChild[] {
- const nodeMap: Record = {};
+function conflictKey(item: UploadEntry): string {
+ return (item.fullPath || item.name).replace(/^\/+/, "");
+}
- // First pass: create all nodes
- flatArray.forEach((item, index) => {
- // File list is created from very different action and info available are not always the same.
- // By doing a drag and drop or upload a folder (both from the browser or from the OS) we have the fullPath property available
- // By uploading a single file using the file input, we only have the "name" property
- // By doing drag and drop from filebrowser to filebrowser, we have the "to" property available but not the fullPath
- const fullPathOrTo =
- item.fullPath || item.to?.replace(basePath, "") || item.name;
- nodeMap[fullPathOrTo!] = {
- fullPath: fullPathOrTo,
- isDir: item.isDir,
- name: item.name,
- size: item.size,
- originalIndex: index,
- ...(item.isDir && { children: [] }),
- ...(item.file && { file: item.file }),
- };
- });
+type ServerConflictEntry = {
+ path: string;
+ name: string;
+ size: number;
+ modified: string;
+};
- const roots: UploadEntryWithChild[] = [];
+async function fetchConflictEntries(
+ basePath: string,
+ includeDirectories: boolean
+): Promise {
+ if (!includeDirectories) {
+ return await api.fetchAll(basePath);
+ }
- // Second pass: build hierarchy
- flatArray.forEach((item) => {
- // see comment before to explanation
- const fullPathOrTo =
- item.fullPath || item.to?.replace(basePath, "") || item.name;
+ const destination = await api.fetch(basePath);
+ return destination.items ?? [];
+}
- const node = nodeMap[fullPathOrTo!];
- const lastSlash = fullPathOrTo!.lastIndexOf("/");
+function conflictPath(entry: ServerConflictEntry): string {
+ return entry.path.replace(/\\/g, "/");
+}
- if (lastSlash === -1) {
- roots.push(node);
- } else {
- const parentPath = fullPathOrTo!.substring(0, lastSlash);
- const parent = nodeMap[parentPath];
- if (parent?.children) {
- parent.children.push(node);
- }
- }
- });
+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 roots;
+ return serverMap;
}
/**
- * Return conflict files from
- * @param files - flat upload list to check
- * @param basePath - server destination path (e.g. "/files/uploads/")
+ * Return the entries from `files` that already exist under `basePath` on the
+ * server, so the caller can prompt the user to overwrite/rename/skip.
+ *
+ * Directory handling differs by action, hence `includeDirectories`:
+ * - 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/")
+ * @param includeDirectories - report directories as conflicts (copy/move)
*/
export async function checkConflict(
files: UploadList,
- basePath: string
+ basePath: string,
+ includeDirectories = false
): Promise {
- console.log(
- "Starting conflict check, " + files.length + " possible conflict found."
- );
+ if (files.length === 0) return [];
- const forest = flatToForest(files, basePath);
- if (forest.length === 0) return [];
-
- // Single API call: fetch the entire server tree under basePath.
- let serverEntries: RecursiveEntry[] = [];
+ let serverEntries: ServerConflictEntry[];
try {
- serverEntries = await api.fetchAll(basePath);
+ serverEntries = await fetchConflictEntries(basePath, includeDirectories);
} catch {
- console.error(
- `Failed to fetch recursive server listing for ${basePath}. ` +
- `Assuming directory doesn't exist and skipping conflict check.`
- );
+ // The destination doesn't exist yet, so nothing can conflict.
return [];
}
- // Build a lookup map keyed by the normalised server path for O(1) access.
- // The server returns paths relative to the user's scope (e.g. "/uploads/foo.txt").
- // We strip the basePath prefix so the key matches the upload entry's fullPath.
- const normBase = removePrefix(basePath).replace(/\/+$/, "");
- const serverMap = new Map();
- for (const entry of serverEntries) {
- // entry.path is absolute from server root, e.g. "/uploads/sub/file.txt"
- // We need the relative part after normBase, e.g. "sub/file.txt"
- let rel = entry.path;
- if (rel.startsWith(normBase)) {
- rel = rel.slice(normBase.length);
- }
- // Strip leading slash so it matches fullPath format ("sub/file.txt")
- rel = rel.replace(/^\/+/, "");
- serverMap.set(rel, entry);
- }
+ const serverMap = buildConflictMap(
+ serverEntries,
+ basePath,
+ includeDirectories
+ );
const conflicts: ConflictingResource[] = [];
+ files.forEach((file, index) => {
+ if (file.isDir && !includeDirectories) return; // see directory note above
- /**
- * Walk the upload tree and compare each file node against the
- * pre-fetched server map. Directories only need to be recursed
- * when they appear in the map (otherwise no child can conflict).
- */
- function recursiveCheckConflict(nodes: UploadEntryWithChild[]): void {
- for (const node of nodes) {
- if (node.isDir && node.children) {
- // Only recurse if this directory exists on the server
- const dirKey = node.fullPath!.replace(/^\/+/, "");
- if (serverMap.has(dirKey)) {
- recursiveCheckConflict(node.children);
- }
- } else {
- // File – check for a conflict against the server map
- const fileKey = node.fullPath!.replace(/^\/+/, "");
- const serverEntry = serverMap.get(fileKey);
+ const server = serverMap.get(conflictKey(file));
+ if (!server) return;
- if (serverEntry) {
- conflicts.push({
- index: node.originalIndex,
- name: serverEntry.path,
- origin: {
- lastModified: node.file?.lastModified,
- size: node.size,
- },
- dest: {
- lastModified: serverEntry.modified,
- size: serverEntry.size,
- },
- checked: ["origin"],
- isSmallerOnServer: node.size > serverEntry.size,
- });
- }
- }
- }
- }
-
- // Walk all root nodes synchronously against the pre-fetched data
- recursiveCheckConflict(forest);
-
- console.log(conflicts.length + " conflicts found.");
-
- conflicts.sort((a, b) => a.index - b.index);
+ conflicts.push({
+ index,
+ name: conflictPath(server),
+ origin: { lastModified: file.file?.lastModified, size: file.size },
+ dest: { lastModified: server.modified, size: server.size },
+ checked: ["origin"],
+ isSmallerOnServer: file.size > server.size,
+ });
+ });
return conflicts;
}
diff --git a/frontend/src/views/files/FileListing.vue b/frontend/src/views/files/FileListing.vue
index 06a010e4..9bf4c488 100644
--- a/frontend/src/views/files/FileListing.vue
+++ b/frontend/src/views/files/FileListing.vue
@@ -632,6 +632,7 @@ const copyCut = (event: Event | KeyboardEvent): void => {
from: fileStore.req.items[i].url,
name: fileStore.req.items[i].name,
size: fileStore.req.items[i].size,
+ isDir: fileStore.req.items[i].isDir,
modified: fileStore.req.items[i].modified,
});
}
@@ -661,6 +662,7 @@ const paste = async (event: Event) => {
to,
name: item.name,
size: item.size,
+ isDir: item.isDir,
modified: item.modified,
overwrite: false,
rename: clipboardStore.path == route.path,
@@ -697,7 +699,7 @@ const paste = async (event: Event) => {
}
const path = route.path.endsWith("/") ? route.path : route.path + "/";
- const conflict = await upload.checkConflict(items, path);
+ const conflict = await upload.checkConflict(items, path, true);
if (conflict.length > 0) {
layoutStore.showHover({
diff --git a/go.mod b/go.mod
index 4442b564..042a6a32 100644
--- a/go.mod
+++ b/go.mod
@@ -4,81 +4,81 @@ 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.19.0
+ github.com/redis/go-redis/v9 v9.21.0
github.com/samber/lo v1.53.0
- github.com/shirou/gopsutil/v4 v4.26.4
+ 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
- golang.org/x/crypto v0.50.0
- golang.org/x/image v0.39.0
- golang.org/x/text v0.36.0
+ go.etcd.io/bbolt v1.5.0
+ golang.org/x/crypto v0.53.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
)
require (
github.com/STARRY-S/zip v0.2.3 // indirect
- github.com/andybalholm/brotli v1.2.0 // indirect
- github.com/asticode/go-astikit v0.56.0 // indirect
- github.com/asticode/go-astits v1.13.0 // 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
- github.com/bodgit/sevenzip v1.6.1 // indirect
+ github.com/bodgit/sevenzip v1.6.4 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
- github.com/ebitengine/purego v0.10.0 // indirect
- github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/ebitengine/purego v0.10.1 // indirect
+ github.com/fsnotify/fsnotify v1.10.1 // indirect
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.4.0 // indirect
- github.com/golang/geo v0.0.0-20250707181242-c5087ca84cf4 // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // 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.0 // 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.0.1 // indirect
- github.com/nwaples/rardecode/v2 v2.2.0 // indirect
- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
- github.com/pierrec/lz4/v4 v4.1.22 // indirect
+ github.com/minio/minlz v1.1.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
github.com/russross/blackfriday/v2 v2.1.0 // indirect
- github.com/sagikazarmark/locafero v0.11.0 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
- github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/cast v1.10.0 // indirect
+ github.com/stangelandcl/ppmd v0.1.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- go4.org v0.0.0-20230225012048-214862532bf5 // indirect
- golang.org/x/net v0.52.0 // indirect
- golang.org/x/sync v0.20.0 // indirect
- golang.org/x/sys v0.43.0 // indirect
+ go4.org v0.0.0-20260112195520-a5071408f32f // indirect
+ golang.org/x/net v0.56.0 // indirect
+ golang.org/x/sync v0.21.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
diff --git a/go.sum b/go.sum
index d756184d..76e51e7e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,60 +1,37 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
-cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
-cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
-cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
-cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
-cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
-cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
-cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
-cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
-cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
-cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
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.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
-github.com/andybalholm/brotli v1.2.0/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.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw=
-github.com/asticode/go-astikit v0.56.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-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.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.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c=
-github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
+github.com/asticode/go-astits v1.15.0 h1:yRyCiUc8Jj4F7clt2GDxHghMpWuFL5rkaLuGUd2/0J4=
+github.com/asticode/go-astits v1.15.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
-github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
-github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
+github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
+github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
+github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -81,89 +58,56 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
-github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
-github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
+github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
-github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
-github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
-github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
-github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
-github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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-20250707181242-c5087ca84cf4 h1:vCeHcs8N7MOccOOsOVIy1xcYu+kBkA4J5urTgigww7c=
-github.com/golang/geo v0.0.0-20250707181242-c5087ca84cf4/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
-github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+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=
-github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
-github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
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/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
-github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
-github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+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=
@@ -186,41 +130,36 @@ github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
-github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
-github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
+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.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=
-github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
-github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
-github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
-github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+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=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
-github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
-github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+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=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
-github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
-github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+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.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
-github.com/shirou/gopsutil/v4 v4.26.4/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/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
-github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -232,6 +171,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
+github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -255,210 +196,68 @@ github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaU
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
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.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
-go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
-go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+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=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
-go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
+go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
+go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
-golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
-golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
-golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
-golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
-golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
-golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
-golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+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.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
-golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
-golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
-golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+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-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
-golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
-golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
-golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
-golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
-golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
-google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
-google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -469,11 +268,3 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
-rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
-rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/http/auth.go b/http/auth.go
index 4381e86c..b96819b8 100644
--- a/http/auth.go
+++ b/http/auth.go
@@ -102,7 +102,7 @@ func withUser(fn handleFunc) handleFunc {
w.Header().Add("X-Renew-Token", "true")
}
- d.user, err = d.store.Users.Get(d.server.Root, tk.User.ID)
+ d.user, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, tk.User.ID)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -201,6 +201,22 @@ var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int,
return http.StatusInternalServerError, err
}
user.Scope = userHome
+
+ // When home directories are created from the username, distinct usernames
+ // can normalize to the same scope (cleanUsername is many-to-one), which would
+ // silently hand the new user another user's home directory. Reject the signup
+ // if the derived scope is already taken. When CreateUserDir is off, all
+ // signups intentionally share the configured default scope, so this check
+ // does not apply.
+ if d.settings.CreateUserDir {
+ switch _, err := d.store.Users.GetByScope(user.Scope); {
+ case err == nil:
+ return http.StatusConflict, fberrors.ErrExist
+ case !errors.Is(err, fberrors.ErrNotExist):
+ return http.StatusInternalServerError, err
+ }
+ }
+
log.Printf("new user: %s, home dir: [%s].", user.Username, userHome)
err = d.store.Users.Save(user)
diff --git a/http/auth_test.go b/http/auth_test.go
new file mode 100644
index 00000000..bacff284
--- /dev/null
+++ b/http/auth_test.go
@@ -0,0 +1,71 @@
+package fbhttp
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/asdine/storm/v3"
+
+ "github.com/filebrowser/filebrowser/v2/settings"
+ "github.com/filebrowser/filebrowser/v2/storage/bolt"
+)
+
+// Regression for the username-normalization home-directory collision
+// (GHSA-7rc3-g7h6-22m7): with Signup and CreateUserDir enabled, two distinct
+// usernames that cleanUsername() normalizes to the same directory must not be
+// handed the same home directory. The second registration is rejected.
+func TestSignupRejectsCollidingNormalizedScope(t *testing.T) {
+ root := t.TempDir()
+
+ db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
+ if err != nil {
+ t.Fatalf("failed to open db: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ st, err := bolt.NewStorage(db)
+ if err != nil {
+ t.Fatalf("failed to get storage: %v", err)
+ }
+ if err := st.Settings.Save(&settings.Settings{
+ Key: []byte("test-signing-key"),
+ Signup: true,
+ CreateUserDir: true,
+ UserHomeBasePath: "/users",
+ MinimumPasswordLength: 1,
+ }); err != nil {
+ t.Fatalf("failed to save settings: %v", err)
+ }
+
+ server := &settings.Server{Root: root}
+
+ signup := func(username string) *httptest.ResponseRecorder {
+ body := `{"username":"` + username + `","password":"CollidePw12345!"}`
+ req, _ := http.NewRequest(http.MethodPost, "/signup", strings.NewReader(body))
+ rec := httptest.NewRecorder()
+ handle(signupHandler, "", st, server).ServeHTTP(rec, req)
+ return rec
+ }
+
+ // Victim registers first and gets /users/teamone-x.
+ if rec := signup("teamone-x"); rec.Code != http.StatusOK {
+ t.Fatalf("first signup: expected 200, got %d body=%q", rec.Code, rec.Body.String())
+ }
+
+ // Attacker picks a distinct username that normalizes to the same scope.
+ if rec := signup("teamone/x"); rec.Code != http.StatusConflict {
+ t.Fatalf("VULNERABLE: colliding signup expected 409, got %d body=%q", rec.Code, rec.Body.String())
+ }
+
+ // The shared scope must still be owned solely by the first user.
+ owner, err := st.Users.GetByScope("/users/teamone-x")
+ if err != nil {
+ t.Fatalf("expected first user to own the scope: %v", err)
+ }
+ if owner.Username != "teamone-x" {
+ t.Fatalf("scope owner = %q, want teamone-x", owner.Username)
+ }
+}
diff --git a/http/public.go b/http/public.go
index fb71d2b8..211f8a87 100644
--- a/http/public.go
+++ b/http/public.go
@@ -9,11 +9,9 @@ import (
"path/filepath"
"strings"
- "github.com/spf13/afero"
- "golang.org/x/crypto/bcrypt"
-
"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/share"
+ "golang.org/x/crypto/bcrypt"
)
var withHashFile = func(fn handleFunc) handleFunc {
@@ -29,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
}
@@ -65,8 +63,12 @@ var withHashFile = func(fn handleFunc) handleFunc {
filePath = ifPath
}
- // set fs root to the shared file/folder
- d.user.Fs = afero.NewBasePathFs(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 89c3bbd3..0343046e 100644
--- a/http/public_symlink_test.go
+++ b/http/public_symlink_test.go
@@ -12,13 +12,13 @@ import (
"testing"
"github.com/asdine/storm/v3"
- "github.com/spf13/afero"
-
+ "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/share"
"github.com/filebrowser/filebrowser/v2/storage"
"github.com/filebrowser/filebrowser/v2/storage/bolt"
"github.com/filebrowser/filebrowser/v2/users"
+ "github.com/spf13/afero"
)
// symlinkShareStorage builds a storage whose single user is rooted at a real
@@ -65,7 +65,7 @@ func symlinkShareStorage(t *testing.T) *storage.Storage {
}
st.Users = &customFSUser{
Store: st.Users,
- fs: afero.NewBasePathFs(afero.NewOsFs(), scope),
+ fs: files.NewScopedFs(afero.NewOsFs(), scope),
}
return st
}
@@ -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 947ee049..c728102d 100644
--- a/http/public_test.go
+++ b/http/public_test.go
@@ -8,13 +8,13 @@ import (
"testing"
"github.com/asdine/storm/v3"
- "github.com/spf13/afero"
-
+ "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/rules"
"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"
+ "github.com/spf13/afero"
)
func TestPublicShareHandlerAuthentication(t *testing.T) {
@@ -220,7 +220,7 @@ func TestPublicShareHandlerRules(t *testing.T) {
t.Fatalf("failed to save settings: %v", err)
}
- fs := afero.NewBasePathFs(afero.NewOsFs(), t.TempDir())
+ fs := files.NewScopedFs(afero.NewOsFs(), t.TempDir())
if err := fs.MkdirAll("/projects/private", 0o755); err != nil {
t.Fatalf("failed to create private dir: %v", err)
}
@@ -269,14 +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
}
- user.Fs = cu.fs
+ // Inject a filesystem rooted at the test's temp scope, standing in for the
+ // one users.User.Clean would build in production.
+ if cu.followExternal {
+ user.Fs = cu.fs
+ } else {
+ user.Fs = files.NewScopedFs(cu.fs, "/")
+ }
return user, nil
}
diff --git a/http/raw.go b/http/raw.go
index ab01eed6..8f8c5751 100644
--- a/http/raw.go
+++ b/http/raw.go
@@ -2,6 +2,7 @@ package fbhttp
import (
"errors"
+ "fmt"
"io/fs"
"log"
"net/http"
@@ -10,11 +11,10 @@ import (
"path/filepath"
"strings"
- "github.com/mholt/archives"
-
"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/fileutils"
"github.com/filebrowser/filebrowser/v2/users"
+ "github.com/mholt/archives"
)
func slashClean(name string) string {
@@ -115,10 +115,6 @@ func getFiles(d *data, path, commonPath string) ([]archives.FileInfo, error) {
return nil, nil
}
- if ok, err := files.WithinScope(d.user.Fs, path); err != nil || !ok {
- return nil, nil
- }
-
info, err := d.user.Fs.Stat(path)
if err != nil {
return nil, err
@@ -130,12 +126,19 @@ func getFiles(d *data, path, commonPath string) ([]archives.FileInfo, error) {
nameInArchive := strings.TrimPrefix(path, commonPath)
nameInArchive = strings.TrimPrefix(nameInArchive, string(filepath.Separator))
nameInArchive = filepath.ToSlash(nameInArchive)
- // filepath.ToSlash only rewrites the host separator, so on a Linux
- // host a stored backslash survives and is emitted verbatim into the
- // archive. Windows extractors then treat "\" as a path separator,
- // allowing the entry to escape the extraction directory (zip-slip).
- // Strip Windows separators regardless of host OS.
- nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "/")
+ // A backslash is a legal filename character on POSIX hosts, so it can
+ // reach here verbatim. Rewriting it to the path separator "/" would
+ // manufacture a traversal sequence (e.g. "..\..\x" -> "../../x") that
+ // escapes the extraction directory on the victim's machine, while
+ // leaving it as "\" lets Windows extractors treat it as a separator.
+ // Neutralize it to an inert character instead of turning it into one.
+ nameInArchive = strings.ReplaceAll(nameInArchive, "\\", "_")
+
+ // Defense in depth: never emit an archive entry whose path escapes the
+ // archive root, regardless of how the name was produced.
+ if cleaned := gopath.Clean("/" + nameInArchive); cleaned != "/"+nameInArchive {
+ return nil, fmt.Errorf("refusing unsafe archive entry name: %q", nameInArchive)
+ }
archiveFiles = append(archiveFiles, archives.FileInfo{
FileInfo: info,
diff --git a/http/raw_test.go b/http/raw_test.go
index c35334f6..7fe3e916 100644
--- a/http/raw_test.go
+++ b/http/raw_test.go
@@ -1,14 +1,75 @@
package fbhttp
import (
+ "archive/zip"
+ "bytes"
"net/http"
"net/http/httptest"
"net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
"testing"
"github.com/filebrowser/filebrowser/v2/files"
+ "github.com/filebrowser/filebrowser/v2/settings"
+ "github.com/filebrowser/filebrowser/v2/users"
)
+// Regression for the archive backslash-to-slash zip-slip (GHSA-83xp-526h-j3ww):
+// a single in-scope file whose name contains backslashes is a legal POSIX
+// filename, not a traversal. The archive builder must never rewrite "\" into the
+// path separator "/", which would manufacture an entry like "../../evil.sh" that
+// escapes the extraction directory on the downloader's machine.
+func TestRawArchiveDoesNotManufactureTraversal(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ if err := os.MkdirAll(filepath.Join(userScope, "ziptest"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // One legal Linux/macOS filename whose bytes include backslashes. It does not
+ // traverse on the server; it only becomes "../../evil.sh" if the builder
+ // turns "\" into "/".
+ planted := filepath.Join(userScope, "ziptest", "..\\..\\evil.sh")
+ if err := os.WriteFile(planted, []byte("#!/bin/sh\necho PWNED"), 0o644); err != nil {
+ t.Skipf("cannot create backslash-named file: %v", err)
+ }
+
+ key := []byte("test-signing-key")
+ perm := users.Permissions{Download: true}
+ st := scopedUserStorage(t, userScope, perm, key)
+ signed := signToken(t, perm, key)
+
+ req, _ := http.NewRequest(http.MethodGet, "/ziptest?algo=zip", http.NoBody)
+ req.Header.Set("X-Auth", signed)
+ rec := httptest.NewRecorder()
+ handle(rawHandler, "", st, &settings.Server{}).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d body=%q", rec.Code, rec.Body.String())
+ }
+
+ zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
+ if err != nil {
+ t.Fatalf("failed to read zip: %v", err)
+ }
+ if len(zr.File) == 0 {
+ t.Fatal("archive has no entries")
+ }
+
+ for _, f := range zr.File {
+ // The entry must be a normalized, root-relative path: no ".." segments
+ // and no leading "/". Note a name may legitimately contain ".." as part of
+ // a single filename (e.g. ".._.._evil.sh"), which Clean leaves untouched —
+ // so compare against the normalized form rather than searching for "..".
+ if strings.HasPrefix(f.Name, "/") || path.Clean("/"+f.Name) != "/"+f.Name {
+ t.Errorf("VULNERABLE: archive entry escapes root: %q", f.Name)
+ }
+ }
+}
+
func TestSetContentDisposition(t *testing.T) {
t.Parallel()
diff --git a/http/resource.go b/http/resource.go
index 5f06a7cd..d8f5cad9 100644
--- a/http/resource.go
+++ b/http/resource.go
@@ -15,12 +15,11 @@ import (
"strings"
"time"
- "github.com/shirou/gopsutil/v4/disk"
- "github.com/spf13/afero"
-
fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/fileutils"
+ "github.com/shirou/gopsutil/v4/disk"
+ "github.com/spf13/afero"
)
var resourceGetHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
@@ -131,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
}
@@ -228,19 +229,6 @@ func resourcePatchHandler(fileCache FileCache) handleFunc {
return http.StatusForbidden, nil
}
- // Refuse to copy/move through a symlink that escapes the user's scope,
- // for either the source (read escape) or the destination (write
- // escape). fileutils.Copy/MoveFile operate on the raw afero FS and
- // follow symlinks, so they bypass the guards in stat()/writeFile().
- for _, p := range []string{src, dst} {
- if ok, scopeErr := files.WithinScope(d.user.Fs, p); scopeErr != nil || !ok {
- if scopeErr != nil {
- return errToStatus(scopeErr), scopeErr
- }
- return http.StatusForbidden, nil
- }
- }
-
err = checkParent(src, dst)
if err != nil {
return http.StatusBadRequest, err
@@ -308,13 +296,6 @@ func addVersionSuffix(source string, afs afero.Fs) string {
}
func writeFile(afs afero.Fs, dst string, in io.Reader, fileMode, dirMode fs.FileMode) (os.FileInfo, error) {
- // Refuse to write through a symlink that escapes the user's scope, so an
- // overwrite of an existing escaping symlink cannot modify a file outside
- // the boundary.
- if ok, err := files.WithinScope(afs, dst); err != nil || !ok {
- return nil, os.ErrPermission
- }
-
dir, _ := path.Split(dst)
err := afs.MkdirAll(dir, dirMode)
if err != nil {
@@ -445,7 +426,10 @@ var resourceGetRecursiveHandler = withUser(func(w http.ResponseWriter, r *http.R
}
entries = append(entries, RecursiveEntry{
- Path: fPath,
+ // afero.Walk joins paths with the OS separator, so on Windows fPath
+ // uses backslashes. The web API contract is forward slashes, so
+ // normalize it here (mirrors search/search.go).
+ Path: filepath.ToSlash(fPath),
Name: info.Name(),
Size: info.Size(),
ModTime: info.ModTime(),
diff --git a/http/resource_test.go b/http/resource_test.go
new file mode 100644
index 00000000..5a29518d
--- /dev/null
+++ b/http/resource_test.go
@@ -0,0 +1,259 @@
+package fbhttp
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/asdine/storm/v3"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/spf13/afero"
+
+ "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"
+)
+
+func TestResourceCopyDoesNotDereferenceEscapingSymlink(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ if err := os.MkdirAll(filepath.Join(userScope, "srcdir"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // An ordinary in-scope file, to prove a normal copy still works.
+ if err := os.WriteFile(filepath.Join(userScope, "srcdir", "normal.txt"), []byte("in-scope"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // The secret living outside the user's scope.
+ secret := filepath.Join(root, "secret.txt")
+ if err := os.WriteFile(secret, []byte("OUT-OF-SCOPE-SECRET"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // An escaping symlink planted inside the user's scope (out-of-band, as the
+ // advisory's preconditions describe).
+ if err := os.Symlink(secret, filepath.Join(userScope, "srcdir", "link.txt")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+
+ key := []byte("test-signing-key")
+
+ 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)
+ }
+ perm := users.Permissions{Create: true, Modify: true, Download: true, Rename: true}
+ 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)
+ }
+ // The user's scope is the real on-disk userScope. customFSUser.Get wraps it
+ // in a ScopedFs, mirroring production (users.User init).
+ st.Users = &customFSUser{
+ Store: st.Users,
+ fs: afero.NewBasePathFs(afero.NewOsFs(), userScope),
+ }
+
+ signed := signToken(t, perm, key)
+
+ t.Run("direct raw read is forbidden", func(t *testing.T) {
+ req, _ := http.NewRequest(http.MethodGet, "/srcdir/link.txt", 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("VULNERABLE: direct raw read of escaping symlink returned 200, body=%q", rec.Body.String())
+ }
+ })
+
+ t.Run("recursive copy does not exfiltrate", func(t *testing.T) {
+ req, _ := http.NewRequest(http.MethodPatch, "/srcdir/", http.NoBody)
+ q := req.URL.Query()
+ q.Set("action", "copy")
+ q.Set("destination", "/dstdir")
+ req.URL.RawQuery = q.Encode()
+ req.Header.Set("X-Auth", signed)
+
+ rec := httptest.NewRecorder()
+ handle(resourcePatchHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)
+ t.Logf("copy status=%d body=%q", rec.Code, rec.Body.String())
+
+ // The escaping symlink's target content must never appear in scope.
+ leaked := filepath.Join(userScope, "dstdir", "link.txt")
+ if data, readErr := os.ReadFile(leaked); readErr == nil {
+ t.Fatalf("VULNERABLE: out-of-scope content landed in scope at %s: %q", leaked, string(data))
+ }
+ })
+}
+
+func signToken(t *testing.T, perm users.Permissions, key []byte) string {
+ t.Helper()
+ claims := &authToken{
+ User: userInfo{ID: 1, Username: "u", 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
+}
+
+// scopedUserStorage returns a storage whose single user (ID 1) is scoped to
+// userScope through a symlink-confining ScopedFs (via customFSUser), mirroring
+// production. Used by the symlink scope-escape regression tests below.
+func scopedUserStorage(t *testing.T, userScope string, perm users.Permissions, key []byte) *storage.Storage {
+ t.Helper()
+ db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
+ if err != nil {
+ t.Fatalf("failed to open db: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ st, err := bolt.NewStorage(db)
+ if err != nil {
+ t.Fatalf("failed to get storage: %v", err)
+ }
+ if err := st.Users.Save(&users.User{Username: "u", Password: "pw", Perm: perm}); err != nil {
+ t.Fatalf("failed to save user: %v", err)
+ }
+ if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil {
+ t.Fatalf("failed to save settings: %v", err)
+ }
+ st.Users = &customFSUser{
+ Store: st.Users,
+ fs: afero.NewBasePathFs(afero.NewOsFs(), userScope),
+ }
+ return st
+}
+
+// Regression for the dangling-symlink write escape (GHSA-8wc8-hf36-mjh9 /
+// GHSA-fh54-6rfh-r8f3): POSTing to an in-scope dangling symlink whose target is
+// outside the scope must not dereference the link to create the out-of-scope
+// file.
+func TestResourcePostRejectsDanglingSymlinkWriteEscape(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ outside := filepath.Join(root, "outside")
+ for _, d := range []string{userScope, outside} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // A dangling symlink inside the scope pointing at a not-yet-existing file
+ // outside it (planted out-of-band, per the advisory preconditions).
+ outsideTarget := filepath.Join(outside, "created.txt")
+ if err := os.Symlink(outsideTarget, filepath.Join(userScope, "evil")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+
+ key := []byte("test-signing-key")
+ perm := users.Permissions{Create: true, Modify: true}
+ st := scopedUserStorage(t, userScope, perm, key)
+ signed := signToken(t, perm, key)
+
+ req, _ := http.NewRequest(http.MethodPost, "/evil?override=true", strings.NewReader("http-outside"))
+ req.Header.Set("X-Auth", signed)
+ rec := httptest.NewRecorder()
+ handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)
+
+ if _, statErr := os.Stat(outsideTarget); statErr == nil {
+ data, _ := os.ReadFile(outsideTarget)
+ t.Fatalf("VULNERABLE: out-of-scope file created via dangling symlink (status=%d, content=%q)", rec.Code, string(data))
+ }
+ if rec.Code != http.StatusForbidden {
+ t.Errorf("expected 403, got %d body=%q", rec.Code, rec.Body.String())
+ }
+}
+
+// Regression for the symlink-following delete escape (GHSA-hq4g-mpch-f9vp /
+// GHSA-fmm7-x4gx-8jhr): a Create-only user POSTing to a child of an escaping
+// symlinked directory must not delete the out-of-scope target through the
+// failed-upload cleanup RemoveAll.
+func TestResourcePostCleanupDoesNotDeleteThroughSymlink(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ outside := filepath.Join(root, "outside")
+ for _, d := range []string{userScope, outside} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ victim := filepath.Join(outside, "victim.txt")
+ if err := os.WriteFile(victim, []byte("keep"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // An escaping directory symlink inside the scope (planted out-of-band).
+ if err := os.Symlink(outside, filepath.Join(userScope, "link")); err != nil {
+ t.Skipf("cannot create symlink: %v", err)
+ }
+
+ key := []byte("test-signing-key")
+ // Create-only: Perm.Delete is deliberately false — the bug must not need it.
+ perm := users.Permissions{Create: true}
+ st := scopedUserStorage(t, userScope, perm, key)
+ signed := signToken(t, perm, key)
+
+ req, _ := http.NewRequest(http.MethodPost, "/link/victim.txt", strings.NewReader("x"))
+ req.Header.Set("X-Auth", signed)
+ rec := httptest.NewRecorder()
+ handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{}).ServeHTTP(rec, req)
+
+ if _, statErr := os.Stat(victim); statErr != nil {
+ t.Fatalf("VULNERABLE: out-of-scope victim.txt deleted by cleanup RemoveAll (status=%d): %v", rec.Code, statErr)
+ }
+}
+
+func TestResourcePostRunsUploadHooksForDirectories(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ if err := os.MkdirAll(userScope, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ key := []byte("test-signing-key")
+ perm := users.Permissions{Create: true}
+ st := scopedUserStorage(t, userScope, perm, key)
+ if err := st.Settings.Save(&settings.Settings{
+ Key: key,
+ Commands: map[string][]string{
+ "after_upload": {"filebrowser-hook-command-that-does-not-exist"},
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ req, _ := http.NewRequest(http.MethodPost, "/created/", http.NoBody)
+ req.Header.Set("X-Auth", signToken(t, perm, key))
+ rec := httptest.NewRecorder()
+ handle(resourcePostHandler(diskcache.NewNoOp()), "", st, &settings.Server{EnableExec: true}).ServeHTTP(rec, req)
+
+ // A missing after_upload command makes the request fail only if the hook ran.
+ // It avoids a platform-specific helper executable while still exercising the
+ // same path the web UI uses for directory uploads.
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("expected directory upload hook failure to return 500, got %d body=%q", rec.Code, rec.Body.String())
+ }
+ if _, err := os.Stat(filepath.Join(userScope, "created")); err != nil {
+ t.Fatalf("expected directory to be created before its after hook, got %v", err)
+ }
+}
diff --git a/http/share.go b/http/share.go
index c73f5daf..8ed4feb4 100644
--- a/http/share.go
+++ b/http/share.go
@@ -7,18 +7,48 @@ import (
"errors"
"fmt"
"net/http"
+ "path/filepath"
"sort"
"strconv"
"strings"
"time"
- "golang.org/x/crypto/bcrypt"
-
fberrors "github.com/filebrowser/filebrowser/v2/errors"
- "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/share"
+ "github.com/filebrowser/filebrowser/v2/users"
+ "golang.org/x/crypto/bcrypt"
)
+// shareResponse is the client-facing representation of a share. It deliberately
+// omits the server-side secrets of share.Link — the bcrypt PasswordHash (which
+// would be crackable offline) and the bypass Token — exposing only whether the
+// share is password-protected via HasPassword.
+type shareResponse struct {
+ Hash string `json:"hash"`
+ Path string `json:"path"`
+ UserID uint `json:"userID"`
+ Expire int64 `json:"expire"`
+ HasPassword bool `json:"hasPassword"`
+}
+
+func toShareResponse(l *share.Link) *shareResponse {
+ return &shareResponse{
+ Hash: l.Hash,
+ Path: l.Path,
+ UserID: l.UserID,
+ Expire: l.Expire,
+ HasPassword: l.PasswordHash != "",
+ }
+}
+
+func toShareResponses(links []*share.Link) []*shareResponse {
+ res := make([]*shareResponse, 0, len(links))
+ for _, l := range links {
+ res = append(res, toShareResponse(l))
+ }
+ return res
+}
+
func withPermShare(fn handleFunc) handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Share || !d.user.Perm.Download {
@@ -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) {
@@ -63,21 +93,47 @@ var shareGetsHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
err error
)
if d.user.Perm.Admin {
- s, err = d.store.Share.GetsByPath(r.URL.Path)
+ s, err = getSharesForAdminPath(d, r.URL.Path)
} else {
s, err = d.store.Share.Gets(r.URL.Path, d.user.ID)
}
if errors.Is(err, fberrors.ErrNotExist) {
- return renderJSON(w, r, []*share.Link{})
+ return renderJSON(w, r, []*shareResponse{})
}
if err != nil {
return http.StatusInternalServerError, err
}
- return renderJSON(w, r, s)
+ return renderJSON(w, r, toShareResponses(s))
})
+func getSharesForAdminPath(d *data, path string) ([]*share.Link, error) {
+ links, err := d.store.Share.All()
+ if err != nil {
+ return nil, err
+ }
+
+ adminPath := filepath.Clean(d.user.FullPath(path))
+ owners := make(map[uint]*users.User)
+ filtered := make([]*share.Link, 0, len(links))
+ for _, link := range links {
+ owner, ok := owners[link.UserID]
+ if !ok {
+ owner, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, link.UserID)
+ if err != nil && !errors.Is(err, fberrors.ErrNotExist) {
+ return nil, err
+ }
+ owners[link.UserID] = owner // owner is nil on ErrNotExist
+ }
+ if owner != nil && filepath.Clean(owner.FullPath(link.Path)) == adminPath {
+ filtered = append(filtered, link)
+ }
+ }
+
+ return filtered, nil
+}
+
var shareDeleteHandler = withPermShare(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
hash := strings.TrimSuffix(r.URL.Path, "/")
hash = strings.TrimPrefix(hash, "/")
@@ -103,20 +159,14 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
// Only allow sharing paths that currently exist. Otherwise a share could be
// created for a non-existent path and would silently start exposing
// whatever file later appears there.
+ //
+ // d.user.Fs is scoped, so Stat also refuses to follow a symlink whose target
+ // escapes the user's scope: that returns a permission error here and so
+ // blocks creating a share that points out of scope.
if _, err := d.user.Fs.Stat(r.URL.Path); err != nil {
return errToStatus(err), err
}
- // Refuse to create a share whose on-disk target escapes the user's scope
- // (e.g. via a symlink), mirroring the read/write guards. The public serve
- // path already rejects these, but blocking creation avoids dangling shares.
- if ok, err := files.WithinScope(d.user.Fs, r.URL.Path); err != nil || !ok {
- if err != nil {
- return errToStatus(err), err
- }
- return http.StatusForbidden, nil
- }
-
var s *share.Link
var body share.CreateBody
if r.Body != nil {
@@ -184,7 +234,7 @@ var sharePostHandler = withPermShare(func(w http.ResponseWriter, r *http.Request
return http.StatusInternalServerError, err
}
- return renderJSON(w, r, s)
+ return renderJSON(w, r, toShareResponse(s))
})
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
diff --git a/http/share_test.go b/http/share_test.go
new file mode 100644
index 00000000..c03a9393
--- /dev/null
+++ b/http/share_test.go
@@ -0,0 +1,164 @@
+package fbhttp
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/asdine/storm/v3"
+ "github.com/golang-jwt/jwt/v5"
+
+ "github.com/filebrowser/filebrowser/v2/settings"
+ "github.com/filebrowser/filebrowser/v2/share"
+ "github.com/filebrowser/filebrowser/v2/storage/bolt"
+ "github.com/filebrowser/filebrowser/v2/users"
+)
+
+func TestAdminShareGetsHandlerMatchesOwnerScope(t *testing.T) {
+ t.Parallel()
+
+ root := t.TempDir()
+ ownerScope := filepath.Join(root, "owner")
+ if err := os.MkdirAll(ownerScope, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(ownerScope, "file.txt"), []byte("shared"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ db, err := storm.Open(filepath.Join(t.TempDir(), "db"))
+ if err != nil {
+ t.Fatalf("failed to open db: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ st, err := bolt.NewStorage(db)
+ if err != nil {
+ t.Fatalf("failed to get storage: %v", err)
+ }
+
+ owner := &users.User{
+ Username: "owner",
+ Password: "pw",
+ Scope: "/owner",
+ Perm: users.Permissions{Share: true, Download: true},
+ }
+ if err := st.Users.Save(owner); err != nil {
+ t.Fatalf("failed to save owner: %v", err)
+ }
+
+ adminPerm := users.Permissions{Admin: true, Share: true, Download: true}
+ admin := &users.User{
+ Username: "admin",
+ Password: "pw",
+ Scope: "/",
+ Perm: adminPerm,
+ }
+ if err := st.Users.Save(admin); err != nil {
+ t.Fatalf("failed to save admin: %v", err)
+ }
+
+ if err := st.Share.Save(&share.Link{Hash: "h", UserID: owner.ID, Path: "/file.txt"}); err != nil {
+ t.Fatalf("failed to save share: %v", err)
+ }
+ key := []byte("test-signing-key")
+ if err := st.Settings.Save(&settings.Settings{Key: key}); err != nil {
+ t.Fatalf("failed to save settings: %v", err)
+ }
+
+ req, err := http.NewRequest(http.MethodGet, "/owner/file.txt", http.NoBody)
+ if err != nil {
+ t.Fatalf("failed to construct request: %v", err)
+ }
+ req.Header.Set("X-Auth", signShareTestToken(t, admin.ID, admin.Username, adminPerm, key))
+
+ rec := httptest.NewRecorder()
+ handle(shareGetsHandler, "", st, &settings.Server{Root: root}).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ var links []*share.Link
+ if err := json.Unmarshal(rec.Body.Bytes(), &links); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+ if len(links) != 1 || links[0].Hash != "h" {
+ t.Fatalf("expected admin to see owner share h, got %#v", links)
+ }
+}
+
+// Regression for the share secret exposure (GHSA-833g-cqhp-h72j): the share API
+// must not serialize the bcrypt password hash or the bypass token, while still
+// persisting them server-side so password-protected shares keep working.
+func TestSharePostHandlerDoesNotLeakSecrets(t *testing.T) {
+ root := t.TempDir()
+ userScope := filepath.Join(root, "user")
+ if err := os.MkdirAll(userScope, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(userScope, "file.txt"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ key := []byte("test-signing-key")
+ perm := users.Permissions{Share: true, Download: true}
+ st := scopedUserStorage(t, userScope, perm, key)
+ signed := signToken(t, perm, key)
+
+ body := `{"password":"ShareSecret123!","expires":"24","unit":"hours"}`
+ req, _ := http.NewRequest(http.MethodPost, "/file.txt", strings.NewReader(body))
+ req.Header.Set("X-Auth", signed)
+ rec := httptest.NewRecorder()
+ handle(sharePostHandler, "", st, &settings.Server{Root: root}).ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d body=%q", rec.Code, rec.Body.String())
+ }
+
+ var resp map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if _, ok := resp["password_hash"]; ok {
+ t.Errorf("VULNERABLE: response leaks password_hash: %s", rec.Body.String())
+ }
+ if _, ok := resp["token"]; ok {
+ t.Errorf("VULNERABLE: response leaks token: %s", rec.Body.String())
+ }
+ if resp["hasPassword"] != true {
+ t.Errorf("expected hasPassword=true, got %v", resp["hasPassword"])
+ }
+
+ // The secrets must still be persisted server-side (storm uses the JSON codec,
+ // so the storage struct's tags must keep emitting them).
+ stored, err := st.Share.GetByHash(resp["hash"].(string))
+ if err != nil {
+ t.Fatalf("share not stored: %v", err)
+ }
+ if stored.PasswordHash == "" || stored.Token == "" {
+ t.Fatalf("server-side secrets not persisted: hash=%q token=%q", stored.PasswordHash, stored.Token)
+ }
+}
+
+func signShareTestToken(t *testing.T, id uint, username string, perm users.Permissions, key []byte) string {
+ t.Helper()
+
+ claims := &authToken{
+ User: userInfo{ID: id, Username: username, Perm: perm},
+ RegisteredClaims: jwt.RegisteredClaims{
+ IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
+ },
+ }
+ signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(key)
+ if err != nil {
+ t.Fatalf("failed to sign token: %v", err)
+ }
+ return signed
+}
diff --git a/http/subtitle.go b/http/subtitle.go
index 07c2dcfc..24b49256 100644
--- a/http/subtitle.go
+++ b/http/subtitle.go
@@ -2,7 +2,9 @@ package fbhttp
import (
"bytes"
+ "io"
"net/http"
+ "regexp"
"strings"
"github.com/asticode/go-astisub"
@@ -10,6 +12,8 @@ import (
"github.com/filebrowser/filebrowser/v2/files"
)
+var srtLineBreakTag = regexp.MustCompile(`(?i)
]*)?\s*/?>`)
+
var subtitleHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Download {
return http.StatusAccepted, nil
@@ -49,7 +53,11 @@ func subtitleFileHandler(w http.ResponseWriter, r *http.Request, file *files.Fil
// load subtitle for conversion to vtt
var sub *astisub.Subtitles
if strings.HasSuffix(file.Name, ".srt") {
- sub, err = astisub.ReadFromSRT(fd)
+ content, readErr := io.ReadAll(fd)
+ if readErr != nil {
+ return http.StatusInternalServerError, readErr
+ }
+ sub, err = astisub.ReadFromSRT(bytes.NewReader(normalizeSRTLineBreaks(content)))
} else if strings.HasSuffix(file.Name, ".ass") || strings.HasSuffix(file.Name, ".ssa") {
sub, err = astisub.ReadFromSSA(fd)
}
@@ -78,3 +86,7 @@ func subtitleFileHandler(w http.ResponseWriter, r *http.Request, file *files.Fil
http.ServeContent(w, r, file.Name, file.ModTime, bytes.NewReader(buf.Bytes()))
return 0, nil
}
+
+func normalizeSRTLineBreaks(content []byte) []byte {
+ return srtLineBreakTag.ReplaceAll(content, []byte("\n"))
+}
diff --git a/http/subtitle_test.go b/http/subtitle_test.go
new file mode 100644
index 00000000..2520d453
--- /dev/null
+++ b/http/subtitle_test.go
@@ -0,0 +1,62 @@
+package fbhttp
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/spf13/afero"
+
+ "github.com/filebrowser/filebrowser/v2/files"
+)
+
+func TestNormalizeSRTLineBreaks(t *testing.T) {
+ input := []byte("first
second
third
fourth
fifth")
+ got := string(normalizeSRTLineBreaks(input))
+ want := "first\nsecond\nthird\nfourth\nfifth"
+ if got != want {
+ t.Fatalf("normalizeSRTLineBreaks() = %q, want %q", got, want)
+ }
+}
+
+func TestSubtitleFileHandlerConvertsSRTBreakTags(t *testing.T) {
+ fs := afero.NewMemMapFs()
+ const path = "/sample.srt"
+ const content = "1\n" +
+ "00:00:01,000 --> 00:00:02,000\n" +
+ "First
Second
Third
Fourth\n\n"
+
+ if err := afero.WriteFile(fs, path, []byte(content), 0o644); err != nil {
+ t.Fatalf("failed to write subtitle: %v", err)
+ }
+ info, err := fs.Stat(path)
+ if err != nil {
+ t.Fatalf("failed to stat subtitle: %v", err)
+ }
+
+ file := &files.FileInfo{
+ Fs: fs,
+ Path: path,
+ Name: "sample.srt",
+ ModTime: info.ModTime(),
+ }
+ req := httptest.NewRequest(http.MethodGet, "/api/subtitle/sample.srt?inline=true", http.NoBody)
+ rec := httptest.NewRecorder()
+
+ status, err := subtitleFileHandler(rec, req, file)
+ if err != nil {
+ t.Fatalf("subtitleFileHandler returned error: %v", err)
+ }
+ if status != 0 {
+ t.Fatalf("subtitleFileHandler status = %d, want 0", status)
+ }
+
+ body := rec.Body.String()
+ if strings.Contains(body, "FirstSecond") {
+ t.Fatalf("WebVTT output collapsed SRT
tags: %q", body)
+ }
+ if !strings.Contains(body, "First\nSecond\nThird\nFourth") {
+ t.Fatalf("WebVTT output = %q, want converted SRT
tags as line breaks", body)
+ }
+}
diff --git a/http/tus_handlers.go b/http/tus_handlers.go
index 8a68161d..fe7ae280 100644
--- a/http/tus_handlers.go
+++ b/http/tus_handlers.go
@@ -11,9 +11,8 @@ import (
"strings"
"time"
- "github.com/spf13/afero"
-
"github.com/filebrowser/filebrowser/v2/files"
+ "github.com/spf13/afero"
)
// keepUploadActive periodically touches the cache entry to prevent eviction during transfer
@@ -45,10 +44,6 @@ func tusPostHandler(cache UploadCache) handleFunc {
return http.StatusForbidden, nil
}
- if ok, scopeErr := files.WithinScope(d.user.Fs, r.URL.Path); scopeErr != nil || !ok {
- return http.StatusForbidden, nil
- }
-
file, err := files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs,
Path: r.URL.Path,
@@ -166,11 +161,6 @@ func tusPatchHandler(cache UploadCache) handleFunc {
if r.Header.Get("Content-Type") != "application/offset+octet-stream" {
return http.StatusUnsupportedMediaType, nil
}
- // Defense in depth: refuse to write through a symlink that escapes the
- // scope, in case the target path is (or sits behind) an escaping link.
- if ok, scopeErr := files.WithinScope(d.user.Fs, r.URL.Path); scopeErr != nil || !ok {
- return http.StatusForbidden, nil
- }
uploadOffset, err := getUploadOffset(r)
if err != nil {
diff --git a/http/tus_symlink_test.go b/http/tus_symlink_test.go
index ac0b8c3d..678347e9 100644
--- a/http/tus_symlink_test.go
+++ b/http/tus_symlink_test.go
@@ -12,6 +12,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/afero"
+ "github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/settings"
"github.com/filebrowser/filebrowser/v2/storage/bolt"
"github.com/filebrowser/filebrowser/v2/users"
@@ -58,7 +59,7 @@ func TestTusHandlersRejectSymlinkScopeEscape(t *testing.T) {
}
st.Users = &customFSUser{
Store: st.Users,
- fs: afero.NewBasePathFs(afero.NewOsFs(), userScope),
+ fs: files.NewScopedFs(afero.NewOsFs(), userScope),
}
// Forge a valid auth token for user ID 1.
diff --git a/http/users.go b/http/users.go
index e61ab00b..13092e3e 100644
--- a/http/users.go
+++ b/http/users.go
@@ -71,7 +71,7 @@ func withSelfOrAdmin(fn handleFunc) handleFunc {
}
var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
- users, err := d.store.Users.Gets(d.server.Root)
+ users, err := d.store.Users.Gets(d.server.Root, d.server.FollowExternalSymlinks)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -88,7 +88,7 @@ var usersGetHandler = withAdmin(func(w http.ResponseWriter, r *http.Request, d *
})
var userGetHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
- u, err := d.store.Users.Get(d.server.Root, d.raw.(uint))
+ u, err := d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, d.raw.(uint))
if errors.Is(err, fberrors.ErrNotExist) {
return http.StatusNotFound, err
}
@@ -228,7 +228,7 @@ var userPutHandler = withSelfOrAdmin(func(w http.ResponseWriter, r *http.Request
}
} else {
var suser *users.User
- suser, err = d.store.Users.Get(d.server.Root, d.raw.(uint))
+ suser, err = d.store.Users.Get(d.server.Root, d.server.FollowExternalSymlinks, d.raw.(uint))
if err != nil {
return http.StatusInternalServerError, err
}
diff --git a/settings/settings.go b/settings/settings.go
index d71be16a..44297fd5 100644
--- a/settings/settings.go
+++ b/settings/settings.go
@@ -47,21 +47,22 @@ func (s *Settings) GetRules() []rules.Rule {
// Server specific settings.
type Server struct {
- Root string `json:"root"`
- BaseURL string `json:"baseURL"`
- Socket string `json:"socket"`
- TLSKey string `json:"tlsKey"`
- TLSCert string `json:"tlsCert"`
- Port string `json:"port"`
- Address string `json:"address"`
- Log string `json:"log"`
- EnableThumbnails bool `json:"enableThumbnails"`
- ResizePreview bool `json:"resizePreview"`
- EnableExec bool `json:"enableExec"`
- TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
- ImageResolutionCal bool `json:"imageResolutionCalculation"`
- AuthHook string `json:"authHook"`
- TokenExpirationTime string `json:"tokenExpirationTime"`
+ Root string `json:"root"`
+ BaseURL string `json:"baseURL"`
+ Socket string `json:"socket"`
+ TLSKey string `json:"tlsKey"`
+ TLSCert string `json:"tlsCert"`
+ Port string `json:"port"`
+ Address string `json:"address"`
+ Log string `json:"log"`
+ EnableThumbnails bool `json:"enableThumbnails"`
+ ResizePreview bool `json:"resizePreview"`
+ EnableExec bool `json:"enableExec"`
+ TypeDetectionByHeader bool `json:"typeDetectionByHeader"`
+ ImageResolutionCal bool `json:"imageResolutionCalculation"`
+ AuthHook string `json:"authHook"`
+ TokenExpirationTime string `json:"tokenExpirationTime"`
+ FollowExternalSymlinks bool `json:"followExternalSymlinks"`
}
// Clean cleans any variables that might need cleaning.
diff --git a/share/storage.go b/share/storage.go
index 684ab018..3b73ef35 100644
--- a/share/storage.go
+++ b/share/storage.go
@@ -110,23 +110,6 @@ func (s *Storage) Gets(path string, id uint) ([]*Link, error) {
return links, nil
}
-// GetsByPath returns all non-expired links for a path.
-func (s *Storage) GetsByPath(path string) ([]*Link, error) {
- links, err := s.All()
- if err != nil {
- return nil, err
- }
-
- filtered := make([]*Link, 0, len(links))
- for _, link := range links {
- if link.Path == path {
- filtered = append(filtered, link)
- }
- }
-
- return filtered, nil
-}
-
// Save wraps a StorageBackend.Save
func (s *Storage) Save(l *Link) error {
return s.back.Save(l)
diff --git a/share/storage_test.go b/share/storage_test.go
deleted file mode 100644
index dd78fe71..00000000
--- a/share/storage_test.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package share
-
-import (
- "reflect"
- "testing"
-)
-
-type fakeBackend struct {
- links []*Link
-}
-
-func (f fakeBackend) All() ([]*Link, error) {
- return f.links, nil
-}
-
-func (f fakeBackend) FindByUserID(id uint) ([]*Link, error) {
- var links []*Link
- for _, link := range f.links {
- if link.UserID == id {
- links = append(links, link)
- }
- }
- return links, nil
-}
-
-func (f fakeBackend) GetByHash(hash string) (*Link, error) {
- for _, link := range f.links {
- if link.Hash == hash {
- return link, nil
- }
- }
- return nil, nil
-}
-
-func (f fakeBackend) GetPermanent(path string, id uint) (*Link, error) {
- for _, link := range f.links {
- if link.Path == path && link.UserID == id && link.Expire == 0 {
- return link, nil
- }
- }
- return nil, nil
-}
-
-func (f fakeBackend) Gets(path string, id uint) ([]*Link, error) {
- var links []*Link
- for _, link := range f.links {
- if link.Path == path && link.UserID == id {
- links = append(links, link)
- }
- }
- return links, nil
-}
-
-func (f fakeBackend) Save(_ *Link) error {
- return nil
-}
-
-func (f fakeBackend) Delete(_ string) error {
- return nil
-}
-
-func (f fakeBackend) DeleteWithPathPrefix(_ string, _ uint) error {
- return nil
-}
-
-func TestGetsByPathReturnsLinksFromAllUsers(t *testing.T) {
- t.Parallel()
-
- expected := []*Link{
- {Hash: "a", Path: "/file.txt", UserID: 1},
- {Hash: "b", Path: "/file.txt", UserID: 2},
- }
- store := NewStorage(fakeBackend{
- links: append(expected, &Link{Hash: "c", Path: "/other.txt", UserID: 3}),
- })
-
- links, err := store.GetsByPath("/file.txt")
- if err != nil {
- t.Fatalf("GetsByPath returned error: %v", err)
- }
-
- if !reflect.DeepEqual(links, expected) {
- t.Fatalf("GetsByPath returned %#v, want %#v", links, expected)
- }
-}
diff --git a/storage/bolt/share.go b/storage/bolt/share.go
index 621ea6f1..49b3f0c4 100644
--- a/storage/bolt/share.go
+++ b/storage/bolt/share.go
@@ -78,16 +78,17 @@ func (s shareBackend) Delete(hash string) error {
}
func (s shareBackend) DeleteWithPathPrefix(pathPrefix string, userID uint) error {
+ // Share paths are stored without a trailing slash
+ prefix := strings.TrimRight(pathPrefix, "/")
+
var links []share.Link
- if err := s.db.Prefix("Path", pathPrefix, &links); err != nil {
+ if err := s.db.Prefix("Path", prefix, &links); err != nil {
if errors.Is(err, storm.ErrNotFound) {
return nil
}
return err
}
- prefix := strings.TrimRight(pathPrefix, "/")
-
var err error
for _, link := range links {
if link.UserID != userID {
diff --git a/storage/bolt/share_test.go b/storage/bolt/share_test.go
index e303cec6..9f96a847 100644
--- a/storage/bolt/share_test.go
+++ b/storage/bolt/share_test.go
@@ -84,6 +84,44 @@ func TestDeleteWithPathPrefix(t *testing.T) {
}
}
+// Regression for the trailing-slash delete leaving a stale share
+// (GHSA-pp88-jhwj-5qh5): deleting "/a/" must remove the exact "/a" share and its
+// descendants, not just the descendants. Siblings and other users are untouched.
+func TestDeleteWithPathPrefixTrailingSlash(t *testing.T) {
+ t.Parallel()
+
+ s := newTestShareBackend(t)
+
+ links := []*share.Link{
+ {Hash: "u1-a", Path: "/a", UserID: 1},
+ {Hash: "u1-a-child", Path: "/a/child.txt", UserID: 1},
+ {Hash: "u1-abc", Path: "/abc", UserID: 1}, // sibling sharing a byte prefix
+ {Hash: "u2-a", Path: "/a", UserID: 2}, // other user, must remain
+ }
+ for _, l := range links {
+ if err := s.Save(l); err != nil {
+ t.Fatalf("failed to save link %s: %v", l.Hash, err)
+ }
+ }
+
+ // Delete with a trailing slash, as the resource delete handler does for a
+ // directory request like DELETE /api/resources/a/.
+ if err := s.DeleteWithPathPrefix("/a/", 1); err != nil {
+ t.Fatalf("DeleteWithPathPrefix returned error: %v", err)
+ }
+
+ got := remainingHashes(t, s)
+ want := []string{"u1-abc", "u2-a"}
+ if len(got) != len(want) {
+ t.Fatalf("remaining hashes = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("remaining hashes = %v, want %v", got, want)
+ }
+ }
+}
+
func TestDeleteWithPathPrefixNoMatch(t *testing.T) {
t.Parallel()
diff --git a/storage/bolt/users.go b/storage/bolt/users.go
index 6686d941..33f67abb 100644
--- a/storage/bolt/users.go
+++ b/storage/bolt/users.go
@@ -6,6 +6,7 @@ import (
"reflect"
"github.com/asdine/storm/v3"
+ "github.com/asdine/storm/v3/q"
bolt "go.etcd.io/bbolt"
fberrors "github.com/filebrowser/filebrowser/v2/errors"
@@ -41,6 +42,19 @@ func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
return
}
+func (st usersBackend) GetByScope(scope string) (*users.User, error) {
+ user := &users.User{}
+ err := st.db.Select(q.Eq("Scope", scope)).First(user)
+ if err != nil {
+ if errors.Is(err, storm.ErrNotFound) {
+ return nil, fberrors.ErrNotExist
+ }
+ return nil, err
+ }
+
+ return user, nil
+}
+
func (st usersBackend) Gets() ([]*users.User, error) {
var allUsers []*users.User
err := st.db.All(&allUsers)
diff --git a/users/storage.go b/users/storage.go
index 33cfc9c4..ff10aeab 100644
--- a/users/storage.go
+++ b/users/storage.go
@@ -10,6 +10,7 @@ import (
// StorageBackend is the interface to implement for a users storage.
type StorageBackend interface {
GetBy(interface{}) (*User, error)
+ GetByScope(scope string) (*User, error)
Gets() ([]*User, error)
Save(u *User) error
Update(u *User, fields ...string) error
@@ -19,8 +20,9 @@ type StorageBackend interface {
}
type Store interface {
- Get(baseScope string, id interface{}) (user *User, err error)
- Gets(baseScope string) ([]*User, error)
+ Get(baseScope string, followExternalSymlinks bool, id interface{}) (user *User, err error)
+ GetByScope(scope string) (*User, error)
+ Gets(baseScope string, followExternalSymlinks bool) ([]*User, error)
Update(user *User, fields ...string) error
Save(user *User) error
Delete(id interface{}) error
@@ -45,26 +47,33 @@ func NewStorage(back StorageBackend) *Storage {
// Get allows you to get a user by its name or username. The provided
// id must be a string for username lookup or a uint for id lookup. If id
// is neither, a ErrInvalidDataType will be returned.
-func (s *Storage) Get(baseScope string, id interface{}) (user *User, err error) {
+func (s *Storage) Get(baseScope string, followExternalSymlinks bool, id interface{}) (user *User, err error) {
user, err = s.back.GetBy(id)
if err != nil {
return
}
- if err := user.Clean(baseScope); err != nil {
+ if err := user.Clean(baseScope, followExternalSymlinks); err != nil {
return nil, err
}
return
}
+// GetByScope returns the first user whose scope matches the given one, or
+// ErrNotExist if none does. The user is returned as stored, without setting up
+// its filesystem, as it is meant for existence checks rather than serving.
+func (s *Storage) GetByScope(scope string) (*User, error) {
+ return s.back.GetByScope(scope)
+}
+
// Gets gets a list of all users.
-func (s *Storage) Gets(baseScope string) ([]*User, error) {
+func (s *Storage) Gets(baseScope string, followExternalSymlinks bool) ([]*User, error) {
users, err := s.back.Gets()
if err != nil {
return nil, err
}
for _, user := range users {
- if err := user.Clean(baseScope); err != nil {
+ if err := user.Clean(baseScope, followExternalSymlinks); err != nil {
return nil, err
}
}
@@ -74,7 +83,7 @@ func (s *Storage) Gets(baseScope string) ([]*User, error) {
// Update updates a user in the database.
func (s *Storage) Update(user *User, fields ...string) error {
- err := user.Clean("", fields...)
+ err := user.Clean("", false, fields...)
if err != nil {
return err
}
@@ -92,7 +101,7 @@ func (s *Storage) Update(user *User, fields ...string) error {
// Save saves the user in a storage.
func (s *Storage) Save(user *User) error {
- if err := user.Clean(""); err != nil {
+ if err := user.Clean("", false); err != nil {
return err
}
diff --git a/users/users.go b/users/users.go
index 7181b299..09db995a 100644
--- a/users/users.go
+++ b/users/users.go
@@ -3,11 +3,10 @@ package users
import (
"path/filepath"
- "github.com/spf13/afero"
-
fberrors "github.com/filebrowser/filebrowser/v2/errors"
"github.com/filebrowser/filebrowser/v2/files"
"github.com/filebrowser/filebrowser/v2/rules"
+ "github.com/spf13/afero"
)
// ViewMode describes a view mode.
@@ -56,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
}
@@ -93,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 = afero.NewBasePathFs(afero.NewOsFs(), scope)
+ u.Fs = files.NewFs(afero.NewOsFs(), scope, followExternalSymlinks)
}
return nil
@@ -101,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.(*afero.BasePathFs), path)
+ return afero.FullBaseFsPath(files.BasePath(u.Fs), path)
}
diff --git a/users/users_test.go b/users/users_test.go
new file mode 100644
index 00000000..87171aaf
--- /dev/null
+++ b/users/users_test.go
@@ -0,0 +1,43 @@
+package users
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/filebrowser/filebrowser/v2/files"
+ "github.com/spf13/afero"
+)
+
+// TestUserCleanFs verifies that Clean builds the user filesystem according to the
+// followExternalSymlinks flag and that FullPath resolves correctly for either
+// implementation.
+func TestUserCleanFs(t *testing.T) {
+ base := t.TempDir()
+ want := filepath.Join(base, "data", "x")
+
+ t.Run("default builds a symlink-confining ScopedFs", func(t *testing.T) {
+ u := &User{Username: "u", Password: "p", Scope: "data"}
+ if err := u.Clean(base, false); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := u.Fs.(*files.ScopedFs); !ok {
+ t.Fatalf("expected *files.ScopedFs, got %T", u.Fs)
+ }
+ if got := u.FullPath("/x"); got != want {
+ t.Fatalf("FullPath: got %q, want %q", got, want)
+ }
+ })
+
+ t.Run("followExternalSymlinks builds a bare BasePathFs", func(t *testing.T) {
+ u := &User{Username: "u", Password: "p", Scope: "data"}
+ if err := u.Clean(base, true); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := u.Fs.(*afero.BasePathFs); !ok {
+ t.Fatalf("expected *afero.BasePathFs, got %T", u.Fs)
+ }
+ if got := u.FullPath("/x"); got != want {
+ t.Fatalf("FullPath: got %q, want %q", got, want)
+ }
+ })
+}
diff --git a/www/docs/authentication.md b/www/docs/authentication.md
index 4308b01c..24833f04 100644
--- a/www/docs/authentication.md
+++ b/www/docs/authentication.md
@@ -46,6 +46,10 @@ The Hook Authentication method in FileBrowser allows developers to delegate user
The hook’s output controls user permissions, scope, locale, and other attributes, making it a powerful and extensible authentication mechanism.
+> [!WARNING]
+>
+> The submitted username and password are attacker-controlled and are handed to your hook command as the `USERNAME` and `PASSWORD` environment variables. File Browser runs the command directly, without a shell, so the values themselves are inert. However, your script must treat them as untrusted: always quote them (`"$USERNAME"`, `"$PASSWORD"`) and never pass them unquoted to a shell, `eval`, `bash -c`, command substitution, or backticks. A hook script that shell-evaluates these values turns any login request into remote code execution.
+
For example, the following code delegates filebrowser authentication to a PowerShell script on Windows. You can configure any command (for example, a script in Python, Node.js, etc.).
```sh
diff --git a/www/docs/cli/filebrowser-config-init.md b/www/docs/cli/filebrowser-config-init.md
index bf13379e..c4e463cc 100644
--- a/www/docs/cli/filebrowser-config-init.md
+++ b/www/docs/cli/filebrowser-config-init.md
@@ -41,6 +41,7 @@ filebrowser config init [flags]
--disableThumbnails disable image thumbnails
--disableTypeDetectionByHeader disables type detection by reading file headers
--fileMode string mode bits that new files are created with (default "0o640")
+ --followExternalSymlinks follow symlinks whose target is outside the user scope (unsafe)
-h, --help help for init
--hideDotfiles hide dotfiles in file listings
--hideLoginButton hide login button from public pages
diff --git a/www/docs/cli/filebrowser-config-set.md b/www/docs/cli/filebrowser-config-set.md
index e17f7058..0b8684e0 100644
--- a/www/docs/cli/filebrowser-config-set.md
+++ b/www/docs/cli/filebrowser-config-set.md
@@ -38,6 +38,7 @@ filebrowser config set [flags]
--disableThumbnails disable image thumbnails
--disableTypeDetectionByHeader disables type detection by reading file headers
--fileMode string mode bits that new files are created with (default "0o640")
+ --followExternalSymlinks follow symlinks whose target is outside the user scope (unsafe)
-h, --help help for set
--hideDotfiles hide dotfiles in file listings
--hideLoginButton hide login button from public pages
diff --git a/www/docs/cli/filebrowser.md b/www/docs/cli/filebrowser.md
index ae76fdd9..266516bf 100644
--- a/www/docs/cli/filebrowser.md
+++ b/www/docs/cli/filebrowser.md
@@ -61,6 +61,7 @@ filebrowser [flags]
--disablePreviewResize disable resize of image previews
--disableThumbnails disable image thumbnails
--disableTypeDetectionByHeader disables type detection by reading file headers
+ --followExternalSymlinks follow symlinks whose target is outside the user scope (unsafe)
-h, --help help for filebrowser
--imageProcessors int image processors count (default 4)
-k, --key string tls key
diff --git a/www/docs/deployment.md b/www/docs/deployment.md
index aa2968fc..57676004 100644
--- a/www/docs/deployment.md
+++ b/www/docs/deployment.md
@@ -1,3 +1,15 @@
+## Self-Registration (Signup)
+
+File Browser allows you to enable user self-registration (signup). This can be enabled via **Settings → Global Settings**, or with `filebrowser config set --signup`. Self-registered users inherit the configured **user defaults**, including the scope.
+
+> [!WARNING]
+>
+> By default, the user scope is the server's root, so a self-registered user could read,
+> modify, and delete every file File Browser serves. To prevent this, either:
+>
+> a. Enable `createUserDir` so each user gets their own directory; or
+> b. If users are meant to share files, set the default scope to something other than the root.
+
## Fail2ban
File Browser does not natively support protection against brute force attacks. Therefore, we suggest using something like [fail2ban](https://github.com/fail2ban/fail2ban), which takes care of that by tracking the logs of your File Browser instance. For more information on how fail2ban works, please refer to their [wiki](https://github.com/fail2ban/fail2ban/wiki).