diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
new file mode 100644
index 00000000000..8d08beae156
--- /dev/null
+++ b/.claude/CLAUDE.md
@@ -0,0 +1,160 @@
+# CLAUDE.md
+
+Guidance for Claude when working in this repository (File Browser, `filebrowser/filebrowser`).
+
+## Repo orientation
+
+- Go backend (`github.com/filebrowser/filebrowser/v2`, ecosystem `go`) + Vue frontend under `frontend/`.
+- The project is in **maintenance-only mode** (see `SECURITY.md`). Prefer small, surgical, well-tested changes.
+- Version scheme: `v2.63.x`. Conventional-commit messages (`fix(scope): …`, `feat: …`, `chore: …`).
+- Verify with `go build ./...`, `go vet ./...`, `go test ./...`. Reuse existing test harnesses (e.g. `signToken`, `scopedUserStorage`, `handle`, `customFSUser`, `mockUserStore`).
+
+---
+
+# Handling security advisories
+
+Use this playbook when asked to triage, verify, fix, or manage GitHub security advisories.
+All advisory state lives on GitHub and is driven with the `gh` CLI. States are
+`triage → draft → published`, plus `closed`.
+
+## 1. Fetch
+
+```bash
+# List by state (also: published, draft, closed)
+gh api '/repos/filebrowser/filebrowser/security-advisories?state=triage&per_page=100' \
+ --jq '.[] | {ghsa_id, severity, summary, state}'
+
+# Full report for one advisory (read .summary and .description)
+gh api /repos/filebrowser/filebrowser/security-advisories/GHSA-xxxx-xxxx-xxxx \
+ --jq '.summary, "---", .description'
+```
+
+Always pull the **published** set too — you need it to dedup against.
+
+## 2. Verify each report — do NOT trust the report text
+
+Read the actual source **at HEAD** and reach one verdict per advisory:
+
+- **CONFIRMED** — defect exists at HEAD. Quote the exact `file:line`.
+- **FIXED** — already patched (find the fix commit).
+- **FALSE / NOT APPLICABLE** — claim is wrong, or targets a different project.
+- **NOT EXPLOITABLE** — pattern exists but no code path can reach the precondition.
+- **DUPLICATE** — of a published advisory, or of another triage advisory.
+
+Common traps — check each before accepting a report:
+
+- **"Incomplete fix of a prior advisory."** Read the original fix commit and confirm the *specific*
+ sibling code path is actually still unguarded — a prior fix may already cover a related path.
+- **Wrong project.** Confirm the referenced files, symbols, and endpoints exist in this repo; reports
+ sometimes describe a fork. If the cited code isn't here, close as not applicable.
+- **"Legacy / upgraded / imported records are affected."** Trace the field's git history and confirm that
+ some released version could actually produce such a record before believing it
+ (`git log -S'Field' -- path`, `git show `, `git tag --contains `).
+- **Overlapping reports.** Two triage advisories may share one root cause — consolidate and fix once.
+- **Known, intentionally-unaddressed classes.** Some areas are known and tracked but not fixed (see
+ `SECURITY.md`'s Known Issues); matching reports are duplicates.
+
+Record, per advisory: verdict, the `file:line` evidence, exploitability preconditions
+(default config? platform-specific? auth required?), and the disposition.
+
+## 3. Fix (one commit per advisory)
+
+- Branch off `master` first; never commit fixes directly to `master`.
+- **One commit per fix**, each referencing the GHSA in the body (`Refs GHSA-xxxx-xxxx-xxxx`).
+- When several advisories share a root cause because parallel code paths diverged, **centralize** the logic
+ (e.g. `settings.CreateUserHome` used by signup, proxy, and hook provisioning) so they cannot drift again.
+- Keep it surgical; match surrounding style.
+
+## 4. Add a regression test per fix
+
+- Reuse the existing harnesses; add a focused test asserting the fixed behavior (and that the legitimate
+ path still works). Commit tests alongside the fixes (`test(scope): …`, `Refs GHSA-…`).
+- Run `go test ./... && go vet ./...` before finishing.
+
+## 5. Severity — set a CVSS vector, don't hand-assert
+
+Set a CVSS v3.1 vector; GitHub derives the score **and** severity from it (overriding the plain `severity`
+field). Encode the real preconditions in the vector so the band is defensible:
+
+- Requires a specific target configuration/platform (e.g. case-insensitive FS) → **AC:H**.
+- Unauthenticated vs needs an account → **PR:N / PR:L**.
+- Keep it consistent with the **predecessor CVE's** rating (an incomplete-fix follow-up should not outrank
+ its parent). Bands: `0.1–3.9` low, `4.0–6.9` medium, `7.0–8.9` high, `9.0–10.0` critical.
+
+```bash
+gh api -X PATCH /repos/filebrowser/filebrowser/security-advisories/GHSA-xxxx-xxxx-xxxx \
+ -f cvss_vector_string='CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H' \
+ --jq '{ghsa_id, severity, score: .cvss.score, vector: .cvss.vector_string}'
+```
+
+## 6. Clean up the title
+
+Rewrite reporter titles to the concise, sentence-case, backtick-free style of the published advisories
+(state the vuln; drop "Incomplete fix of…" prefixes and jargon). The title is the `summary` field:
+
+```bash
+gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx \
+ -f summary='Proxy-auth auto-provisioning ignores createUserDir and grants the server root scope'
+```
+
+## 7. Rewrite the body into the standard structure
+
+Reporter reports arrive in whatever shape the reporter used. Before drafting, rewrite the
+`description` into the sections below — **reusing the reporter's own wording wherever it is
+accurate**, rather than paraphrasing it. Drop the greeting, the offer to help, and any claim the
+verification in step 2 disproved. Keep sections in this order and omit the ones that don't apply:
+
+| Section | Contents |
+| --- | --- |
+| `## Summary` | What the defect is, in two or three sentences. Note the version it was reported against and the range it was verified over. |
+| `## Details` | Root cause, naming `file.go`, the function, and a short quote of the **pre-fix** code. |
+| `## PoC` | The reporter's reproduction steps and observed result, trimmed to the essentials. |
+| `## Impact` | Who can exploit it (privilege level, preconditions) and what they get. |
+| `## Patches` | Fixed version plus a link to the fix commit, and one sentence on what the fix does. Mention it if the reporter re-tested and confirmed. |
+| `## Workarounds` | Real mitigations, or `None. Upgrade to vX.Y.Z.` |
+| `## Out of scope` | Anything in the original report deliberately **not** treated as a vulnerability, with the reasoning. Needed whenever the advisory is narrower than the report. |
+| `## References` | Fix and regression-test commit links. |
+
+Use `##` headings (matching the published advisories) and keep the body in the maintainer's voice —
+first person ("I reproduced…") belongs only inside quoted PoC steps.
+
+Send it as a file so the Markdown survives shell quoting:
+
+```bash
+jq -Rs '{description: .}' desc.md \
+ | gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx --input -
+```
+
+## 8. Set affected & patched versions
+
+The package is always `{ecosystem: "go", name: "github.com/filebrowser/filebrowser/v2"}`.
+
+- `vulnerable_version_range` — `<= ` (the newest tag; find it with
+ `git tag --list 'v2.*' --sort=-version:refname | head -1`).
+- `patched_versions` — the **next** release that will actually ship the fix. It doesn't exist just
+ because the branch does; it still has to be cut. **When unsure which version that will be, ask.**
+
+Replace the placeholder versions below before sending:
+
+```bash
+printf '%s' '{"vulnerabilities":[{"package":{"ecosystem":"go","name":"github.com/filebrowser/filebrowser/v2"},"vulnerable_version_range":"<= ","patched_versions":"","vulnerable_functions":[]}]}' \
+ | gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx --input -
+```
+
+## 9. Move state / close, by disposition
+
+```bash
+gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx -f state=draft # fixed, awaiting release
+gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx -f state=closed # duplicate / N-A / not-exploitable
+```
+
+- **CONFIRMED & fixed** → metadata done → **draft**. Publish after the patched release ships.
+- **DUPLICATE / NOT APPLICABLE / NOT EXPLOITABLE** → **closed**.
+- **DEFERRED** (confirmed but not yet fixed) → leave in **triage** with a note.
+- The REST API **cannot post advisory comments** — replies to reporters (dup notice, evidence, links to
+ the relevant tracking issue) must be posted manually in the advisory UI. Draft the text for the maintainer.
+
+## 10. Release & publish
+
+Push the branch, open a PR, merge, tag/release the `patched_versions` you set, then publish the drafts.
+Confirm outward-facing/irreversible advisory actions (close, publish) with the maintainer before doing them.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83fb21fb8e7..b6d5229833b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,35 @@
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.21](https://github.com/filebrowser/filebrowser/compare/v2.63.20...v2.63.21) (2026-07-26)
+
+### Bug Fixes
+
+* **http:** canonicalize paths before checking access rules ([#6045](https://github.com/filebrowser/filebrowser/issues/6045)) ([e6d70cf](https://github.com/filebrowser/filebrowser/commit/e6d70cf24c0cd79a1787601dc99104ec7e7ca3ef))
+
+### Reverts
+
+* Revert "chore(deps): update all non-major dependencies (#5946)" ([0dd8905](https://github.com/filebrowser/filebrowser/commit/0dd89058867f87a7bc04aa7517d21528a280e27c)), references [#5946](https://github.com/filebrowser/filebrowser/issues/5946)
+## [2.63.20](https://github.com/filebrowser/filebrowser/compare/v2.63.19...v2.63.20) (2026-07-25)
+
+### Bug Fixes
+
+* use aria-selected ([67e893e](https://github.com/filebrowser/filebrowser/commit/67e893eee7ee411e166d3fcd759a87b6f0971277))
+* **users:** make the provisioned scope check atomic with the save ([fb6aeba](https://github.com/filebrowser/filebrowser/commit/fb6aeba9eae7b8eb401e0db325973781e1ffd08b))
+## [2.63.19](https://github.com/filebrowser/filebrowser/compare/v2.63.18...v2.63.19) (2026-07-25)
+
+### Bug Fixes
+
+* accessibility and security improvements ([#6033](https://github.com/filebrowser/filebrowser/issues/6033)) ([b21b124](https://github.com/filebrowser/filebrowser/commit/b21b1245ae1b57f031b2f5d787f32a17532402c0))
+* **auth:** isolate auto-provisioned proxy and hook users to their own home ([8ddd3d1](https://github.com/filebrowser/filebrowser/commit/8ddd3d1db9b9f5727d0bf96ea0e7d9a25a8692b4))
+* **http:** delete abandoned TUS uploads through the scoped filesystem ([9bd79c3](https://github.com/filebrowser/filebrowser/commit/9bd79c3aaeb4a55b0e69cf8976c4a258db2f5e06))
+* **http:** enforce declared Upload-Length on TUS uploads ([4daddec](https://github.com/filebrowser/filebrowser/commit/4daddec6f200b03a721197d8c0b4b652c994894e))
+* **http:** enforce download permission on the checksum branch ([6c69b5c](https://github.com/filebrowser/filebrowser/commit/6c69b5cd895e15b29e563200a9a6dfc27b06525e))
+* **http:** run upload hooks for directories ([#6034](https://github.com/filebrowser/filebrowser/issues/6034)) ([9b78324](https://github.com/filebrowser/filebrowser/commit/9b78324d773c790951cc6a97840c4b55f66b5f3d))
+* process --FollowExternalSymlinks ([c05c668](https://github.com/filebrowser/filebrowser/commit/c05c66814891c3cccef394162e428444b53394e4))
+* return error instead of panicking on an unreadable directory during copy ([#6020](https://github.com/filebrowser/filebrowser/issues/6020)) ([ac46cf0](https://github.com/filebrowser/filebrowser/commit/ac46cf06719575477d5125e7472037c204b3702d))
+* **storage:** reject case-folded home directory collisions ([4b8a8d7](https://github.com/filebrowser/filebrowser/commit/4b8a8d72ce554dde378b5091da74aa930ea18327))
+* **upload:** handle encoded path conflicts safely ([#6040](https://github.com/filebrowser/filebrowser/issues/6040)) ([7361d91](https://github.com/filebrowser/filebrowser/commit/7361d91ea2e8cc200a0f40ac84adcd02aedc9ed2))
## [2.63.18](https://github.com/filebrowser/filebrowser/compare/v2.63.17...v2.63.18) (2026-07-04)
diff --git a/README.md b/README.md
index f2ed7957737..cc305b56c5d 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,6 @@
[](https://github.com/filebrowser/filebrowser/actions/workflows/ci.yaml)
-[](https://goreportcard.com/report/github.com/filebrowser/filebrowser/v2)
[](https://github.com/filebrowser/filebrowser/releases/latest)
File Browser provides a file managing interface within a specified directory and it can be used to upload, delete, preview and edit your files. It is a **create-your-own-cloud**-kind of software where you can just install it on your server, direct it to a path and access your files through a nice web interface.
@@ -19,7 +18,7 @@ This project is a finished product which fulfills its goal: be a single binary w
- It can take a while until someone gets back to you. Please be patient.
- [Issues](https://github.com/filebrowser/filebrowser/issues) are meant to track bugs. Unrelated issues will be converted into [discussions](https://github.com/filebrowser/filebrowser/discussions).
- The priority is triaging issues, addressing security issues and reviewing pull requests meant to solve bugs.
-- No new features are planned. Pull requests for new features are not guaranteed to be reviewed.
+- No new features are planned. Pull requests for new features will not be reviewed.
Please read [@hacdias' personal reflection](https://hacdias.com/2026/03/11/filebrowser/) on the project status.
diff --git a/SECURITY.md b/SECURITY.md
index 490a9bea961..4d8bb2a816f 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,25 +2,32 @@
## Supported Versions
-Use this section to tell people about which versions of your project are
-currently being supported with security updates.
+| Version | Supported |
+| ------- | --------- |
+| 2.x | ✅ |
+| < 2.0 | ❌ |
-| Version | Supported |
-| ------- | ------------------ |
-| 2.x | :white_check_mark: |
-| < 2.0 | :x: |
+## Before Reporting
-## Reporting a Vulnerability
+This project is in maintenance-only mode. To avoid duplicates, first check the [existing advisories](https://github.com/filebrowser/filebrowser/security/advisories) and open issues, and confirm:
+
+- **It concerns this project, not a fork.** Reports about code, features, or endpoints that don't exist here belong to the relevant fork.
+- **It isn't an already-known class** that remains unaddressed:
+ - Command execution, runner, and hooks (opt-in, disabled by default) — [#5199](https://github.com/filebrowser/filebrowser/issues/5199)
+ - Session and JWT handling — [#5216](https://github.com/filebrowser/filebrowser/issues/5216)
-Vulnerabilities with critical impact should be reported on the [Security](https://github.com/filebrowser/filebrowser/security) page of this repository, which is a private way of communicating vulnerabilities to maintainers. This project is in maintenance-only mode and it can take a while until someone gets back to you.
+Reports covering these are likely to be closed as duplicates.
+
+## Reporting a Vulnerability
-If it is not a critical vulnerability, please open an issue and we will categorize it as a security issue. By giving visibility, we can get more help from the community at fixing such issues.
+- **Critical:** report privately via the [Security](https://github.com/filebrowser/filebrowser/security) page.
+- **Non-critical:** open a public issue so the community can help; we'll label it as a security issue.
-When reporting an issue, where possible, please provide at least:
+Please include, where possible:
-* The commit version the issue was identified at
-* A proof of concept (plaintext; no binaries)
-* Steps to reproduce
-* Your recommended remediation(s), if any.
+- The commit the issue was found at
+- A plaintext proof of concept (no binaries)
+- Steps to reproduce
+- Recommended remediation, if any
-The File Browser team is a volunteer-only effort, and may reach back out for clarification.
+We're a volunteer effort, so responses can take a while, and we may reach out for clarification.
diff --git a/auth/hook.go b/auth/hook.go
index a6dc25b8b1b..6653cd9b30c 100644
--- a/auth/hook.go
+++ b/auth/hook.go
@@ -157,15 +157,16 @@ func (a *HookAuth) SaveUser() (*users.User, error) {
}
u = a.GetUser(d)
- userHome, err := a.Settings.MakeUserDir(u.Username, u.Scope, a.Server.Root)
+ // A scope explicitly returned by the hook takes precedence over the
+ // automatic per-user home directory derivation.
+ _, explicitScope := a.Fields.Values["user.scope"]
+ derivedScope, err := a.Settings.CreateUserHome(u, a.Server.Root, explicitScope)
if err != nil {
- return nil, fmt.Errorf("user: failed to mkdir user home dir: [%s]", userHome)
+ return nil, err
}
- u.Scope = userHome
- log.Printf("user: %s, home dir: [%s].", u.Username, userHome)
+ log.Printf("user: %s, home dir: [%s].", u.Username, u.Scope)
- err = a.Users.Save(u)
- if err != nil {
+ if err := a.Users.SaveProvisioned(u, derivedScope); err != nil {
return nil, err
}
} else if p := !users.CheckPwd(a.Cred.Password, u.Password); len(a.Fields.Values) > 1 || p {
diff --git a/auth/hook_test.go b/auth/hook_test.go
index 4b0112b1b4f..10819759a65 100644
--- a/auth/hook_test.go
+++ b/auth/hook_test.go
@@ -5,6 +5,9 @@ import (
"path/filepath"
"runtime"
"testing"
+
+ "github.com/filebrowser/filebrowser/v2/settings"
+ "github.com/filebrowser/filebrowser/v2/users"
)
// writeHookScript writes a POSIX shell script to a temp file and returns its
@@ -86,3 +89,68 @@ fi
t.Fatalf("expected action %q, got %q", "auth", action)
}
}
+
+// newHookAuth builds a HookAuth for a freshly provisioned user with the given
+// parsed hook fields (hook.action=auth is added automatically).
+func newHookAuth(store *mockUserStore, s *settings.Settings, srv *settings.Server, username string, fields map[string]string) *HookAuth {
+ fields["hook.action"] = "auth"
+ return &HookAuth{
+ Users: store,
+ Settings: s,
+ Server: srv,
+ Cred: hookCred{Username: username, Password: "a-strong-password"},
+ Fields: hookFields{Values: fields},
+ }
+}
+
+// With CreateUserDir enabled and no explicit scope from the hook, a provisioned
+// hook user must receive its own home directory rather than the server root.
+func TestHookSaveUserCreateUserDirIsolatesScope(t *testing.T) {
+ t.Parallel()
+
+ store := &mockUserStore{users: make(map[string]*users.User)}
+ srv := &settings.Server{Root: t.TempDir()}
+ s := &settings.Settings{
+ Key: []byte("key"),
+ CreateUserDir: true,
+ UserHomeBasePath: "/users",
+ Defaults: settings.UserDefaults{
+ Scope: ".",
+ Perm: users.Permissions{Create: true},
+ },
+ }
+
+ u, err := newHookAuth(store, s, srv, "alice", map[string]string{}).SaveUser()
+ if err != nil {
+ t.Fatalf("SaveUser error: %v", err)
+ }
+ if u.Scope != "/users/alice" {
+ t.Errorf("hook user without explicit scope: expected /users/alice, got %q", u.Scope)
+ }
+}
+
+// A scope explicitly returned by the hook takes precedence over the automatic
+// per-user home directory derivation.
+func TestHookSaveUserRespectsExplicitScope(t *testing.T) {
+ t.Parallel()
+
+ store := &mockUserStore{users: make(map[string]*users.User)}
+ srv := &settings.Server{Root: t.TempDir()}
+ s := &settings.Settings{
+ Key: []byte("key"),
+ CreateUserDir: true,
+ UserHomeBasePath: "/users",
+ Defaults: settings.UserDefaults{
+ Scope: ".",
+ Perm: users.Permissions{Create: true},
+ },
+ }
+
+ u, err := newHookAuth(store, s, srv, "teamlead", map[string]string{"user.scope": "/shared/team"}).SaveUser()
+ if err != nil {
+ t.Fatalf("SaveUser error: %v", err)
+ }
+ if u.Scope != "/shared/team" {
+ t.Errorf("explicit hook scope should win, got %q", u.Scope)
+ }
+}
diff --git a/auth/proxy.go b/auth/proxy.go
index ab6227d4f40..5550f102b33 100644
--- a/auth/proxy.go
+++ b/auth/proxy.go
@@ -50,15 +50,12 @@ func (a ProxyAuth) createUser(usr users.Store, setting *settings.Settings, srv *
user.Perm.Execute = false
user.Commands = []string{}
- var userHome string
- userHome, err = setting.MakeUserDir(user.Username, user.Scope, srv.Root)
- if err != nil {
+ var derivedScope bool
+ if derivedScope, err = setting.CreateUserHome(user, srv.Root, false); err != nil {
return nil, err
}
- user.Scope = userHome
- err = usr.Save(user)
- if err != nil {
+ if err = usr.SaveProvisioned(user, derivedScope); err != nil {
return nil, err
}
diff --git a/auth/proxy_test.go b/auth/proxy_test.go
index 9b9ef3a790d..5d9b5ace680 100644
--- a/auth/proxy_test.go
+++ b/auth/proxy_test.go
@@ -2,6 +2,7 @@ package auth
import (
"net/http"
+ "strings"
"testing"
fberrors "github.com/filebrowser/filebrowser/v2/errors"
@@ -22,13 +23,31 @@ func (m *mockUserStore) Get(_ string, _ bool, id interface{}) (*users.User, erro
return nil, fberrors.ErrNotExist
}
-func (m *mockUserStore) GetByScope(_ string) (*users.User, error) { return nil, fberrors.ErrNotExist }
+func (m *mockUserStore) GetByScope(scope string) (*users.User, error) {
+ for _, u := range m.users {
+ if strings.EqualFold(u.Scope, scope) {
+ return u, nil
+ }
+ }
+ 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) SaveProvisioned(user *users.User, derivedScope bool) error {
+ if derivedScope {
+ if _, err := m.GetByScope(user.Scope); err == nil {
+ return fberrors.ErrExist
+ }
+ }
+ return m.Save(user)
+}
+
func (m *mockUserStore) Delete(_ interface{}) error { return nil }
func (m *mockUserStore) LastUpdate(_ uint) int64 { return 0 }
@@ -78,3 +97,46 @@ func TestProxyAuthCreateUserRestrictsDefaults(t *testing.T) {
t.Error("auto-provisioned proxy user should retain Create permission from defaults")
}
}
+
+// With CreateUserDir enabled, two distinct proxy-authenticated users must each
+// receive their own home directory instead of both inheriting the server root.
+func TestProxyAuthCreateUserDirIsolatesScope(t *testing.T) {
+ t.Parallel()
+
+ store := &mockUserStore{users: make(map[string]*users.User)}
+ srv := &settings.Server{Root: t.TempDir()}
+ s := &settings.Settings{
+ Key: []byte("key"),
+ AuthMethod: MethodProxyAuth,
+ CreateUserDir: true,
+ UserHomeBasePath: "/users",
+ Defaults: settings.UserDefaults{
+ Scope: ".",
+ Perm: users.Permissions{Create: true},
+ },
+ }
+
+ auth := ProxyAuth{Header: "X-Remote-User"}
+ provision := func(name string) *users.User {
+ req, _ := http.NewRequest(http.MethodGet, "/", http.NoBody)
+ req.Header.Set("X-Remote-User", name)
+ u, err := auth.Auth(req, store, s, srv)
+ if err != nil {
+ t.Fatalf("Auth(%q) error: %v", name, err)
+ }
+ return u
+ }
+
+ alice := provision("alice")
+ bob := provision("bob")
+
+ if alice.Scope == "/" || bob.Scope == "/" {
+ t.Fatalf("provisioned users inherited the server root: alice=%q bob=%q", alice.Scope, bob.Scope)
+ }
+ if alice.Scope == bob.Scope {
+ t.Fatalf("distinct users must get distinct scopes, both got %q", alice.Scope)
+ }
+ if alice.Scope != "/users/alice" {
+ t.Errorf("expected /users/alice, got %q", alice.Scope)
+ }
+}
diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go
index e4b45c4784b..91a9c63283e 100644
--- a/cmd/cmd_test.go
+++ b/cmd/cmd_test.go
@@ -5,6 +5,10 @@ import (
"github.com/samber/lo"
"github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+
+ "github.com/filebrowser/filebrowser/v2/auth"
+ "github.com/filebrowser/filebrowser/v2/settings"
)
// TestEnvCollisions ensures that there are no collisions in the produced environment
@@ -33,3 +37,25 @@ func testEnvCollisions(t *testing.T, cmd *cobra.Command) {
t.Errorf("Found duplicate environment variable keys for command %q: %v", cmd.Name(), duplicates)
}
}
+
+// TestGetSettingsFollowExternalSymlinks ensures that the followExternalSymlinks
+// flag is persisted to the server config when set via "config set".
+func TestGetSettingsFollowExternalSymlinks(t *testing.T) {
+ flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
+ addConfigFlags(flags)
+
+ if err := flags.Parse([]string{"--followExternalSymlinks"}); err != nil {
+ t.Fatal(err)
+ }
+
+ set := &settings.Settings{AuthMethod: auth.MethodJSONAuth}
+ ser := &settings.Server{}
+
+ if _, err := getSettings(flags, set, ser, &auth.JSONAuth{}, false); err != nil {
+ t.Fatal(err)
+ }
+
+ if !ser.FollowExternalSymlinks {
+ t.Error("expected FollowExternalSymlinks to be persisted as true")
+ }
+}
diff --git a/cmd/config.go b/cmd/config.go
index 415c7c65982..9961d7c7a35 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -319,6 +319,8 @@ func getSettings(flags *pflag.FlagSet, set *settings.Settings, ser *settings.Ser
case "disableImageResolutionCalc":
ser.ImageResolutionCal, err = flags.GetBool(flag.Name)
ser.ImageResolutionCal = !ser.ImageResolutionCal
+ case "followExternalSymlinks":
+ ser.FollowExternalSymlinks, err = flags.GetBool(flag.Name)
// Settings flags from [addConfigFlags]
case "signup":
diff --git a/files/case.go b/files/case.go
new file mode 100644
index 00000000000..24f06bb26cc
--- /dev/null
+++ b/files/case.go
@@ -0,0 +1,34 @@
+package files
+
+import (
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "github.com/spf13/afero"
+)
+
+// CaseInsensitive reports whether the filesystem backing root treats file names
+// case-insensitively, as NTFS, APFS, HFS+, exFAT and CIFS do. It creates a
+// probe file and looks it up under a different case, mirroring how git detects
+// core.ignoreCase.
+//
+// It probes rather than inferring from runtime.GOOS because neither direction
+// holds: macOS can be formatted case-sensitively, and a Linux host commonly
+// serves a case-insensitive mount. When the root cannot be written to, it falls
+// back to the host's usual default rather than assuming case-sensitivity, since
+// a read-only root is still exposed to the disclosure this guards against.
+func CaseInsensitive(fs afero.Fs, root string) bool {
+ probe, err := afero.TempFile(fs, root, "fb-case-probe-")
+ if err != nil {
+ return runtime.GOOS == "windows" || runtime.GOOS == "darwin"
+ }
+
+ name := probe.Name()
+ probe.Close()
+ defer fs.Remove(name) //nolint:errcheck
+
+ dir, base := filepath.Split(name)
+ _, err = fs.Stat(filepath.Join(dir, strings.ToUpper(base)))
+ return err == nil
+}
diff --git a/files/case_test.go b/files/case_test.go
new file mode 100644
index 00000000000..8feb708cdf1
--- /dev/null
+++ b/files/case_test.go
@@ -0,0 +1,44 @@
+package files
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/spf13/afero"
+)
+
+// MemMapFs keys its entries by exact name, so it is case-sensitive.
+func TestCaseInsensitiveReportsCaseSensitiveFs(t *testing.T) {
+ fs := afero.NewMemMapFs()
+ if err := fs.MkdirAll("/root", 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ if CaseInsensitive(fs, "/root") {
+ t.Error("CaseInsensitive() = true for a case-sensitive filesystem; want false")
+ }
+ assertNoProbeLeft(t, fs, "/root")
+}
+
+// Whatever the verdict, the probe file must not be left behind in the user's
+// data directory.
+func TestCaseInsensitiveRemovesProbe(t *testing.T) {
+ root := t.TempDir()
+ fs := afero.NewOsFs()
+
+ t.Logf("CaseInsensitive(%s) = %v", root, CaseInsensitive(fs, root))
+ assertNoProbeLeft(t, fs, root)
+}
+
+func assertNoProbeLeft(t *testing.T, fs afero.Fs, root string) {
+ t.Helper()
+ entries, err := afero.ReadDir(fs, root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if strings.HasPrefix(e.Name(), "fb-case-probe-") {
+ t.Errorf("probe file left behind: %s", e.Name())
+ }
+ }
+}
diff --git a/frontend/public/index.html b/frontend/public/index.html
index 15ff375ec7b..dc59f230878 100644
--- a/frontend/public/index.html
+++ b/frontend/public/index.html
@@ -5,7 +5,7 @@
[{[ if .ReCaptcha -]}]
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 25917f7d468..543b50b84ec 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,5 +1,6 @@
+
{{ name }}
@@ -9,6 +10,7 @@ import { ref, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import { setHtmlLocale } from "./i18n";
import { getMediaPreference, getTheme, setTheme } from "./utils/theme";
+import { name } from "./utils/constants";
const { locale } = useI18n();
@@ -31,3 +33,17 @@ watch(locale, (newValue) => {
newValue && setHtmlLocale(newValue);
});
+
+
diff --git a/frontend/src/components/files/ExtendedImage.vue b/frontend/src/components/files/ExtendedImage.vue
index 88b78304709..62b8d56680b 100644
--- a/frontend/src/components/files/ExtendedImage.vue
+++ b/frontend/src/components/files/ExtendedImage.vue
@@ -57,6 +57,9 @@ const container = ref(null);
onMounted(() => {
if (!decodeUTIF() && imgex.value !== null) {
imgex.value.src = props.src;
+ imgex.value.alt = decodeURIComponent(
+ props.src.split("/").pop() || "preview"
+ );
}
props.classList.forEach((className) =>
@@ -88,6 +91,9 @@ watch(
() => {
if (!decodeUTIF() && imgex.value !== null) {
imgex.value.src = props.src;
+ imgex.value.alt = decodeURIComponent(
+ props.src.split("/").pop() || "preview"
+ );
}
scale.value = 1;
diff --git a/frontend/src/components/files/ListingItem.vue b/frontend/src/components/files/ListingItem.vue
index 33b5a99358e..143adda80fb 100644
--- a/frontend/src/components/files/ListingItem.vue
+++ b/frontend/src/components/files/ListingItem.vue
@@ -26,6 +26,7 @@
diff --git a/frontend/src/components/header/HeaderBar.vue b/frontend/src/components/header/HeaderBar.vue
index d15ec06019f..bf281bcbf75 100644
--- a/frontend/src/components/header/HeaderBar.vue
+++ b/frontend/src/components/header/HeaderBar.vue
@@ -1,6 +1,6 @@
-
+