Compare commits

..
10 Commits
Author SHA1 Message Date
Henrique Dias 85b7d2762d chore(release): 2.63.6 2026-06-03 11:31:10 +02:00
Henrique Dias 4edabb9ccc chore(docs): update CLI documentation 2026-06-03 11:30:59 +02:00
transifex-integration[bot]andGitHub 103683069e chore: Updates for project File Browser (#5947) 2026-06-03 11:25:04 +02:00
Ariel LeyvaandGitHub 5328e80d2e fix: parse csv files with uneven columns in their rows (#5965) 2026-06-03 11:22:05 +02:00
Henrique DiasandClaude Opus 4.8 847d08bdd1 fix: address three security disclosures (archive traversal, login DoS, symlink escape)
- http/raw.go: strip Windows backslash separators from archive entry names
  on any host. filepath.ToSlash is a no-op for "\" on Linux, so a stored
  backslash filename was emitted verbatim and could escape the extraction
  directory on Windows extractors (zip-slip). (GHSA-gxjx-7m74-hcq8)

- http/auth.go: cap the login and signup request bodies with
  http.MaxBytesReader (1 MiB). The JSON decoder previously read an
  arbitrarily large password into memory before bcrypt truncated it,
  enabling unauthenticated memory-exhaustion DoS. (GHSA-w5fm-68j4-fpc4)

- files/file.go, http/resource.go: add files.WithinScope and refuse to
  follow a symlink whose on-disk target escapes the user's scoped root,
  on both the read path (stat) and the write path (writeFile). Prevents a
  scoped user from reading/overwriting/sharing files outside their scope
  via a pre-existing escaping symlink. (GHSA-239w-m3h6-ch8v)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:21:00 +02:00
Henrique Dias 0231b7ebdf fix: cross-user unauthorized share-link deletion 2026-06-03 10:31:44 +02:00
Henrique Dias e07c59df0b fix: incorrect access control in public directory shares via rule path rebasing 2026-06-03 10:24:05 +02:00
Henrique Dias 0d3eb9bea9 docs: clarify hide dotfiles 2026-06-03 10:20:12 +02:00
Henrique Dias 34ae34e764 fix: remove undocumented hook auth with shell replacement 2026-06-03 09:59:36 +02:00
Henrique Dias ca0108f070 chore: disable automatic major updates 2026-05-21 16:52:04 +02:00
24 changed files with 436 additions and 35 deletions
+11
View File
@@ -2,6 +2,17 @@
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.6](https://github.com/filebrowser/filebrowser/compare/v2.63.5...v2.63.6) (2026-06-03)
### Bug Fixes
* address three security disclosures (archive traversal, login DoS, symlink escape) ([847d08b](https://github.com/filebrowser/filebrowser/commit/847d08bdd135e5c3659f2e6dea2f0cd36617af9b))
* cross-user unauthorized share-link deletion ([0231b7e](https://github.com/filebrowser/filebrowser/commit/0231b7ebdfbe77a6c54027d30c4856c3fd81ee4d))
* incorrect access control in public directory shares via rule path rebasing ([e07c59d](https://github.com/filebrowser/filebrowser/commit/e07c59df0b850f5924d5b1683e8609661ddcf534))
* parse csv files with uneven columns in their rows ([#5965](https://github.com/filebrowser/filebrowser/issues/5965)) ([5328e80](https://github.com/filebrowser/filebrowser/commit/5328e80d2e88d1c279a1250a7dfee4fc96f703ec))
* remove undocumented hook auth with shell replacement ([34ae34e](https://github.com/filebrowser/filebrowser/commit/34ae34e764d72540c039f1f5ea2ec4c974168c1f))
## [2.63.5](https://github.com/filebrowser/filebrowser/compare/v2.63.4...v2.63.5) (2026-05-21)
-16
View File
@@ -86,22 +86,6 @@ func (a *HookAuth) LoginPage() bool {
// RunCommand starts the hook command and returns the action
func (a *HookAuth) RunCommand() (string, error) {
command := strings.Split(a.Command, " ")
envMapping := func(key string) string {
switch key {
case "USERNAME":
return a.Cred.Username
case "PASSWORD":
return a.Cred.Password
default:
return os.Getenv(key)
}
}
for i, arg := range command {
if i == 0 {
continue
}
command[i] = os.Expand(arg, envMapping)
}
cmd := exec.Command(command[0], command[1:]...)
cmd.Env = append(os.Environ(), fmt.Sprintf("USERNAME=%s", a.Cred.Username))
+88
View File
@@ -0,0 +1,88 @@
package auth
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// writeHookScript writes a POSIX shell script to a temp file and returns its
// path, marking it executable.
func writeHookScript(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "hook.sh")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o700); err != nil {
t.Fatalf("failed to write hook script: %v", err)
}
return path
}
// TestRunCommandNoCredentialInjection ensures that attacker-controlled
// credentials submitted at the unauthenticated login endpoint cannot be
// injected into the hook command string. Credentials must only ever reach the
// hook through the USERNAME/PASSWORD environment variables, never via string
// substitution into the command itself (CWE-78/CWE-88).
func TestRunCommandNoCredentialInjection(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
marker := filepath.Join(t.TempDir(), "pwned")
// The hook simply blocks. If the credential were ever interpolated into the
// command string and evaluated by a shell, the embedded `touch` would
// create the marker file.
script := writeHookScript(t, "echo hook.action=block\n")
a := &HookAuth{
Command: script,
Cred: hookCred{
Username: `"; touch ` + marker + `; #`,
Password: `$(touch ` + marker + `)`,
},
}
action, err := a.RunCommand()
if err != nil {
t.Fatalf("RunCommand returned error: %v", err)
}
if action != "block" {
t.Fatalf("expected action %q, got %q", "block", action)
}
if _, err := os.Stat(marker); err == nil {
t.Fatalf("credential injection executed: marker file %q was created", marker)
}
}
// TestRunCommandReceivesCredentialsViaEnv verifies the supported contract: the
// hook receives credentials through the USERNAME and PASSWORD environment
// variables.
func TestRunCommandReceivesCredentialsViaEnv(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell")
}
script := writeHookScript(t, `if [ "$USERNAME" = alice ] && [ "$PASSWORD" = secret ]; then
echo hook.action=auth
else
echo hook.action=block
fi
`)
a := &HookAuth{
Command: script,
Cred: hookCred{
Username: "alice",
Password: "secret",
},
}
action, err := a.RunCommand()
if err != nil {
t.Fatalf("RunCommand returned error: %v", err)
}
if action != "auth" {
t.Fatalf("expected action %q, got %q", "auth", action)
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ func addUserFlags(flags *pflag.FlagSet) {
flags.Bool("singleClick", false, "use single clicks only")
flags.Bool("redirectAfterCopyMove", false, "redirect to destination after copy/move")
flags.Bool("dateFormat", false, "use date format (true for absolute time, false for relative)")
flags.Bool("hideDotfiles", false, "hide dotfiles")
flags.Bool("hideDotfiles", false, "hide dotfiles in file listings")
flags.String("aceEditorTheme", "", "ace editor's syntax highlighting theme for users")
}
+55
View File
@@ -133,6 +133,17 @@ func stat(opts *FileOptions) (*FileInfo, error) {
return file, nil
}
// The path is a symlink. Refuse to follow it if its on-disk target escapes
// the user's scoped root; otherwise a symlink that lives lexically inside
// the scope but points outside it would let a restricted user read, write,
// or share files beyond their boundary.
if file != nil && file.IsSymlink {
ok, scopeErr := WithinScope(opts.Fs, opts.Path)
if scopeErr != nil || !ok {
return nil, os.ErrPermission
}
}
// fs doesn't support afero.Lstater interface or the file is a symlink
info, err := opts.Fs.Stat(opts.Path)
if err != nil {
@@ -165,6 +176,50 @@ 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
}
return resolved == root || strings.HasPrefix(resolved, root+string(filepath.Separator)), 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 {
+5 -1
View File
@@ -138,7 +138,11 @@ watchEffect(() => {
: props.content;
parse(
content as string,
{ delimiter: columnSeparator.value, skip_empty_lines: true },
{
delimiter: columnSeparator.value,
skip_empty_lines: true,
relax_column_count: true,
},
(error, output) => {
if (error) {
console.error("Failed to parse CSV:", error);
+4 -4
View File
@@ -29,8 +29,8 @@
"rename": "Renommer",
"replace": "Remplacer",
"reportIssue": "Signaler un problème",
"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": "Reprendre le transfert précédent",
"resumeTransferTooltip": "Ignorez tous les fichiers en conflit, à l'exception de ceux dont la taille est inférieure sur le serveur, car nous supposons que leur transfert a été interrompu.",
"save": "Enregistrer",
"schedule": "Planifier",
"search": "Rechercher",
@@ -247,7 +247,7 @@
"execute": "Exécuter des commandes",
"modify": "Modifier des fichiers",
"rename": "Renommer ou déplacer des fichiers ou des dossiers",
"share": "Share files (require download permission)"
"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",
@@ -283,7 +283,7 @@
"currentPassword": "Mot de Passe Actuel"
},
"sidebar": {
"diskUsed": "{used} of {total} used",
"diskUsed": "{used} sur {total} utilisés",
"help": "Aide",
"hugoNew": "Nouveau Hugo",
"login": "Login",
+1 -1
View File
@@ -29,7 +29,7 @@
"rename": "Hernoemen",
"replace": "Vervangen",
"reportIssue": "Probleem melden",
"resumeTransfer": "Resume previous transfer",
"resumeTransfer": "Vorige overdracht hervatten",
"resumeTransferTooltip": "Alle conflicterende bestanden overslaan, behalve de bestanden die kleiner zijn op de server, omdat we veronderstellen dat hun overdracht is onderbroken.",
"save": "Opslaan",
"schedule": "Plannen",
+1 -1
View File
@@ -29,7 +29,7 @@
"rename": "Zmień nazwę",
"replace": "Zamień",
"reportIssue": "Zgłoś problem",
"resumeTransfer": "Resume previous transfer",
"resumeTransfer": "Wznów poprzedni transfer",
"resumeTransferTooltip": "Pomiń wszystkie pliki powodujące konflikty, z wyjątkiem tych, które są mniejsze na serwerze, ponieważ podejrzewamy, że ich transfer został przerwany.",
"save": "Zapisz",
"schedule": "Harmonogram",
+9 -1
View File
@@ -20,6 +20,8 @@ import (
const (
DefaultTokenExpirationTime = time.Hour * 2
maxAuthBodySize = 1 << 20 // 1 MiB
)
type userInfo struct {
@@ -120,6 +122,10 @@ func withAdmin(fn handleFunc) handleFunc {
func loginHandler(tokenExpireTime time.Duration) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, maxAuthBodySize)
}
auther, err := d.store.Auth.Get(d.settings.AuthMethod)
if err != nil {
return http.StatusInternalServerError, err
@@ -142,7 +148,7 @@ type signupBody struct {
Password string `json:"password"`
}
var signupHandler = func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
var signupHandler = func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.settings.Signup {
return http.StatusMethodNotAllowed, nil
}
@@ -151,6 +157,8 @@ var signupHandler = func(_ http.ResponseWriter, r *http.Request, d *data) (int,
return http.StatusBadRequest, nil
}
r.Body = http.MaxBytesReader(w, r.Body, maxAuthBodySize)
info := &signupBody{}
err := json.NewDecoder(r.Body).Decode(info)
if err != nil {
+16
View File
@@ -3,6 +3,7 @@ package fbhttp
import (
"log"
"net/http"
gopath "path"
"strconv"
"github.com/tomasen/realip"
@@ -23,10 +24,25 @@ type data struct {
store *storage.Storage
user *users.User
raw interface{}
// checkerPrefix is prepended to every path before evaluating rules. It is
// set when the user's filesystem has been rebased onto a subdirectory (as
// done for public shares), so that rules — which are relative to the user's
// original scope — are still matched against the real path instead of the
// rebased one. Empty for regular requests.
checkerPrefix string
}
// Check implements rules.Checker.
func (d *data) Check(path string) bool {
// When the filesystem has been rebased (e.g. a public share rooted at a
// subdirectory), the incoming path is relative to that root. Resolve it
// back to the user's original scope before matching rules, otherwise rules
// targeting paths below the share root would be silently bypassed.
if d.checkerPrefix != "" {
path = gopath.Join(d.checkerPrefix, path)
}
if d.user.HideDotfiles && rules.MatchHidden(path) {
return false
}
+5
View File
@@ -67,6 +67,11 @@ var withHashFile = func(fn handleFunc) handleFunc {
// set fs root to the shared file/folder
d.user.Fs = afero.NewBasePathFs(d.user.Fs, basePath)
// 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
// original scope so deny rules below the share root keep applying.
d.checkerPrefix = basePath
file, err = files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs,
Path: filePath,
+105
View File
@@ -10,6 +10,7 @@ import (
"github.com/asdine/storm/v3"
"github.com/spf13/afero"
"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"
@@ -143,6 +144,110 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
}
}
// TestPublicShareHandlerRules ensures that owner rules keep applying to paths
// below a shared directory, even though the share rebases the filesystem onto
// that directory. A deny rule relative to the owner's scope must not be
// bypassable by requesting the blocked path through the public share.
func TestPublicShareHandlerRules(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
handler handleFunc
path string
expectedStatusCode int
}{
"blocked file via dl handler, 403": {
handler: publicDlHandler,
path: "h/private/secret.txt",
expectedStatusCode: 403,
},
"blocked dir listing via share handler, 403": {
handler: publicShareHandler,
path: "h/private/",
expectedStatusCode: 403,
},
"blocked dir download via dl handler, 403": {
handler: publicDlHandler,
path: "h/private/",
expectedStatusCode: 403,
},
"allowed file via dl handler, 200": {
handler: publicDlHandler,
path: "h/public/readme.txt",
expectedStatusCode: 200,
},
"allowed dir listing via share handler, 200": {
handler: publicShareHandler,
path: "h/public/",
expectedStatusCode: 200,
},
}
for name, tc := range testCases {
name, tc := name, tc
t.Run(name, func(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "db")
db, err := storm.Open(dbPath)
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("failed to close db: %v", err)
}
})
storage, err := bolt.NewStorage(db)
if err != nil {
t.Fatalf("failed to get storage: %v", err)
}
if err := storage.Share.Save(&share.Link{Hash: "h", UserID: 1, Path: "/projects"}); err != nil {
t.Fatalf("failed to save share: %v", err)
}
if err := storage.Users.Save(&users.User{
Username: "username",
Password: "pw",
Perm: users.Permissions{Share: true, Download: true},
Rules: []rules.Rule{
{Allow: false, Path: "/projects/private"},
},
}); err != nil {
t.Fatalf("failed to save user: %v", err)
}
if err := storage.Settings.Save(&settings.Settings{Key: []byte("key")}); err != nil {
t.Fatalf("failed to save settings: %v", err)
}
fs := afero.NewMemMapFs()
if err := afero.WriteFile(fs, "/projects/private/secret.txt", []byte("top secret"), 0o600); err != nil {
t.Fatalf("failed to write secret file: %v", err)
}
if err := afero.WriteFile(fs, "/projects/public/readme.txt", []byte("hello"), 0o600); err != nil {
t.Fatalf("failed to write public file: %v", err)
}
storage.Users = &customFSUser{
Store: storage.Users,
fs: fs,
}
req := newHTTPRequest(t, func(r *http.Request) { r.URL.Path = tc.path })
recorder := httptest.NewRecorder()
handler := handle(tc.handler, "", storage, &settings.Server{})
handler.ServeHTTP(recorder, req)
result := recorder.Result()
defer result.Body.Close()
if result.StatusCode != tc.expectedStatusCode {
t.Errorf("expected status code %d, got status code %d", tc.expectedStatusCode, result.StatusCode)
}
})
}
}
func newHTTPRequest(t *testing.T, requestModifiers ...func(*http.Request)) *http.Request {
t.Helper()
r, err := http.NewRequest(http.MethodGet, "h", http.NoBody)
+6
View File
@@ -125,6 +125,12 @@ 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, "\\", "/")
archiveFiles = append(archiveFiles, archives.FileInfo{
FileInfo: info,
+8 -1
View File
@@ -100,7 +100,7 @@ func resourceDeleteHandler(fileCache FileCache) handleFunc {
return errToStatus(err), err
}
err = d.store.Share.DeleteWithPathPrefix(file.Path)
err = d.store.Share.DeleteWithPathPrefix(file.Path, d.user.ID)
if err != nil {
log.Printf("WARNING: Error(s) occurred while deleting associated shares with file: %s", err)
}
@@ -295,6 +295,13 @@ 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 {
+1
View File
@@ -5,6 +5,7 @@
"group:allNonMajor",
"group:allDigest",
":disableDependencyDashboard",
":disableMajorUpdates",
":semanticCommitTypeAll(chore)"
],
"postUpdateOptions": [
+3 -3
View File
@@ -15,7 +15,7 @@ type StorageBackend interface {
Gets(path string, id uint) ([]*Link, error)
Save(s *Link) error
Delete(hash string) error
DeleteWithPathPrefix(path string) error
DeleteWithPathPrefix(path string, userID uint) error
}
// Storage is a storage.
@@ -137,6 +137,6 @@ func (s *Storage) Delete(hash string) error {
return s.back.Delete(hash)
}
func (s *Storage) DeleteWithPathPrefix(path string) error {
return s.back.DeleteWithPathPrefix(path)
func (s *Storage) DeleteWithPathPrefix(path string, userID uint) error {
return s.back.DeleteWithPathPrefix(path, userID)
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (f fakeBackend) Delete(_ string) error {
return nil
}
func (f fakeBackend) DeleteWithPathPrefix(_ string) error {
func (f fakeBackend) DeleteWithPathPrefix(_ string, _ uint) error {
return nil
}
+15 -1
View File
@@ -2,6 +2,7 @@ package bolt
import (
"errors"
"strings"
"github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
@@ -76,14 +77,27 @@ func (s shareBackend) Delete(hash string) error {
return err
}
func (s shareBackend) DeleteWithPathPrefix(pathPrefix string) error {
func (s shareBackend) DeleteWithPathPrefix(pathPrefix string, userID uint) error {
var links []share.Link
if err := s.db.Prefix("Path", pathPrefix, &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 {
continue
}
if link.Path != prefix && !strings.HasPrefix(link.Path, prefix+"/") {
continue
}
err = errors.Join(err, s.db.DeleteStruct(&share.Link{Hash: link.Hash}))
}
return err
+97
View File
@@ -0,0 +1,97 @@
package bolt
import (
"os"
"sort"
"testing"
"github.com/asdine/storm/v3"
"github.com/filebrowser/filebrowser/v2/share"
)
func newTestShareBackend(t *testing.T) shareBackend {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "shares-*.db")
if err != nil {
t.Fatalf("failed to create temp db: %v", err)
}
_ = f.Close()
db, err := storm.Open(f.Name())
if err != nil {
t.Fatalf("failed to open db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return shareBackend{db: db}
}
func remainingHashes(t *testing.T, s shareBackend) []string {
t.Helper()
links, err := s.All()
if err != nil {
t.Fatalf("All returned error: %v", err)
}
hashes := make([]string, 0, len(links))
for _, link := range links {
hashes = append(hashes, link.Hash)
}
sort.Strings(hashes)
return hashes
}
func TestDeleteWithPathPrefix(t *testing.T) {
t.Parallel()
s := newTestShareBackend(t)
links := []*share.Link{
// user 1's links
{Hash: "u1-a", Path: "/a", UserID: 1},
{Hash: "u1-a-child", Path: "/a/child.txt", UserID: 1},
{Hash: "u1-abc", Path: "/abc", UserID: 1}, // not a descendant of /a
{Hash: "u1-other", Path: "/other", UserID: 1},
// user 2's links — must never be touched when user 1 deletes
{Hash: "u2-a", Path: "/a", UserID: 2},
{Hash: "u2-a-child", Path: "/a/child.txt", UserID: 2},
}
for _, l := range links {
if err := s.Save(l); err != nil {
t.Fatalf("failed to save link %s: %v", l.Hash, err)
}
}
// User 1 deletes their directory /a. Only user 1's /a and its descendants
// should be removed; /abc (sibling sharing a byte prefix) and all of user
// 2's links must remain.
if err := s.DeleteWithPathPrefix("/a", 1); err != nil {
t.Fatalf("DeleteWithPathPrefix returned error: %v", err)
}
got := remainingHashes(t, s)
want := []string{"u1-abc", "u1-other", "u2-a", "u2-a-child"}
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()
s := newTestShareBackend(t)
// No links exist at all: the storm Prefix query returns ErrNotFound, which
// must be treated as a no-op rather than surfaced as an error.
if err := s.DeleteWithPathPrefix("/a", 1); err != nil {
t.Fatalf("DeleteWithPathPrefix on empty store returned error: %v", err)
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ filebrowser config init [flags]
--disableTypeDetectionByHeader disables type detection by reading file headers
--fileMode string mode bits that new files are created with (default "0o640")
-h, --help help for init
--hideDotfiles hide dotfiles
--hideDotfiles hide dotfiles in file listings
--hideLoginButton hide login button from public pages
-k, --key string tls key
--locale string locale for users (default "en")
+1 -1
View File
@@ -39,7 +39,7 @@ filebrowser config set [flags]
--disableTypeDetectionByHeader disables type detection by reading file headers
--fileMode string mode bits that new files are created with (default "0o640")
-h, --help help for set
--hideDotfiles hide dotfiles
--hideDotfiles hide dotfiles in file listings
--hideLoginButton hide login button from public pages
-k, --key string tls key
--locale string locale for users (default "en")
+1 -1
View File
@@ -17,7 +17,7 @@ filebrowser users add <username> <password> [flags]
--commands strings a list of the commands a user can execute
--dateFormat use date format (true for absolute time, false for relative)
-h, --help help for add
--hideDotfiles hide dotfiles
--hideDotfiles hide dotfiles in file listings
--locale string locale for users (default "en")
--lockPassword lock password
--perm.admin admin perm for users
+1 -1
View File
@@ -18,7 +18,7 @@ filebrowser users update <id|username> [flags]
--commands strings a list of the commands a user can execute
--dateFormat use date format (true for absolute time, false for relative)
-h, --help help for update
--hideDotfiles hide dotfiles
--hideDotfiles hide dotfiles in file listings
--locale string locale for users (default "en")
--lockPassword lock password
-p, --password string new password