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 @@

[![Build](https://github.com/filebrowser/filebrowser/actions/workflows/ci.yaml/badge.svg)](https://github.com/filebrowser/filebrowser/actions/workflows/ci.yaml) -[![Go Report Card](https://goreportcard.com/badge/github.com/filebrowser/filebrowser/v2)](https://goreportcard.com/report/github.com/filebrowser/filebrowser/v2) [![Version](https://img.shields.io/github/release/filebrowser/filebrowser.svg)](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 @@ @@ -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 @@