From b7dc392838f11d188f0bc0f2a1a99fad3f7dca03 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Tue, 21 Jul 2026 19:18:51 +0200 Subject: [PATCH 01/40] docs: fix typo --- www/docs/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/docs/installation.md b/www/docs/installation.md index 6acb8db6254..909df9219ab 100644 --- a/www/docs/installation.md +++ b/www/docs/installation.md @@ -47,7 +47,7 @@ docker run \ Where `filebrowser_data`, `filebrowser_database` and `filebrowser_config` are Docker [volumes](https://docs.docker.com/engine/storage/volumes/), where the data, database and configuration will be stored, respectively. The default configuration and database will be automatically initialized. -The default user that runs File Browser inside the container has PID 1000 and GID 1000. If, for one reason or another, you want to run the Docker container with a different user, please consult Docker's [user documentation](https://docs.docker.com/engine/containers/run/#user). +The default user that runs File Browser inside the container has UID 1000 and GID 1000. If, for one reason or another, you want to run the Docker container with a different user, please consult Docker's [user documentation](https://docs.docker.com/engine/containers/run/#user). > [!NOTE] > From c05c66814891c3cccef394162e428444b53394e4 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sat, 25 Jul 2026 07:25:26 +0200 Subject: [PATCH 02/40] fix: process --FollowExternalSymlinks --- cmd/cmd_test.go | 26 ++++++++++++++++++++++++++ cmd/config.go | 2 ++ 2 files changed, 28 insertions(+) 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 cf923eac0e8..b292f01d8df 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -314,6 +314,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": From 7361d91ea2e8cc200a0f40ac84adcd02aedc9ed2 Mon Sep 17 00:00:00 2001 From: Archit Goyal Date: Sat, 25 Jul 2026 10:59:49 +0530 Subject: [PATCH 03/40] fix(upload): handle encoded path conflicts safely (#6040) --- .../utils/__tests__/check-conflict.test.ts | 98 ++++++++ frontend/src/utils/upload.ts | 15 +- frontend/src/views/files/FileListing.vue | 4 +- .../upload-conflict-resolution.test.ts | 225 ++++++++++++++++++ 4 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 frontend/src/views/files/__tests__/upload-conflict-resolution.test.ts diff --git a/frontend/src/utils/__tests__/check-conflict.test.ts b/frontend/src/utils/__tests__/check-conflict.test.ts index c60b33abca9..75f31ef2c60 100644 --- a/frontend/src/utils/__tests__/check-conflict.test.ts +++ b/frontend/src/utils/__tests__/check-conflict.test.ts @@ -286,4 +286,102 @@ describe("checkConflict", () => { expect(conflicts).toHaveLength(1); }); + + it.each([ + ["forward slashes", "/target/My SubDir/file.txt"], + ["Windows backslashes", "\\target\\My SubDir\\file.txt"], + ])( + "detects conflicts below an encoded destination path with %s", + async (_label, serverPath) => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: serverPath, + name: "file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + ]); + + const conflicts = await checkConflict( + [moveItem("file.txt", "/files/target/My%20SubDir/")], + "/files/target/My%20SubDir/" + ); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0].name).toBe("/target/My SubDir/file.txt"); + } + ); + + it("detects nested folder-upload conflicts below an encoded destination path", async () => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: "\\target\\My SubDir\\folder\\nested file.txt", + name: "nested file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + ]); + + const conflicts = await checkConflict( + [ + { + name: "nested file.txt", + size: 12, + isDir: false, + fullPath: "folder/nested file.txt", + }, + ], + "/files/target/My%20SubDir/" + ); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0].name).toBe("/target/My SubDir/folder/nested file.txt"); + }); + + it("leaves malformed destination path segments unchanged", async () => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: "/target/bad%ZZ/file.txt", + name: "file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + ]); + + const conflicts = await checkConflict( + [moveItem("file.txt", "/files/target/bad%ZZ/")], + "/files/target/bad%ZZ/" + ); + + expect(conflicts).toHaveLength(1); + }); + + it("does not match files outside the decoded destination path", async () => { + vi.mocked(api.fetchAll).mockResolvedValue([ + { + path: "/target/My SubDir/other.txt", + name: "other.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + { + path: "/target/My Other SubDir/file.txt", + name: "file.txt", + size: 10, + modified: "2026-06-04T00:00:00Z", + isDir: false, + }, + ]); + + const conflicts = await checkConflict( + [moveItem("file.txt", "/files/target/My%20SubDir/")], + "/files/target/My%20SubDir/" + ); + + expect(conflicts).toHaveLength(0); + }); }); diff --git a/frontend/src/utils/upload.ts b/frontend/src/utils/upload.ts index fec3c2a6957..d331f7f2dc1 100644 --- a/frontend/src/utils/upload.ts +++ b/frontend/src/utils/upload.ts @@ -44,13 +44,26 @@ function conflictPath(entry: ServerConflictEntry): string { return entry.path.replace(/\\/g, "/"); } +function decodePath(path: string): string { + return path + .split("/") + .map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } + }) + .join("/"); +} + function buildConflictMap( serverEntries: ServerConflictEntry[], basePath: string, includeDirectories: boolean ): Map { const serverMap = new Map(); - const normBase = removePrefix(basePath).replace(/\/+$/, ""); + const normBase = decodePath(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. diff --git a/frontend/src/views/files/FileListing.vue b/frontend/src/views/files/FileListing.vue index 9bf4c48833f..77b41ae356e 100644 --- a/frontend/src/views/files/FileListing.vue +++ b/frontend/src/views/files/FileListing.vue @@ -846,7 +846,7 @@ const drop = async (event: DragEvent) => { } } if (files.length > 0) { - upload.handleFiles(files, path, true); + upload.handleFiles(files, path); fileStore.preselect = preselect; } }, @@ -902,7 +902,7 @@ const uploadInput = async (event: Event) => { } } if (uploadFiles.length > 0) { - upload.handleFiles(uploadFiles, path, true); + upload.handleFiles(uploadFiles, path); } }, }); diff --git a/frontend/src/views/files/__tests__/upload-conflict-resolution.test.ts b/frontend/src/views/files/__tests__/upload-conflict-resolution.test.ts new file mode 100644 index 00000000000..3e1ede9cad2 --- /dev/null +++ b/frontend/src/views/files/__tests__/upload-conflict-resolution.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import FileListing from "@/views/files/FileListing.vue"; +import UploadPrompt from "@/components/prompts/Upload.vue"; + +const harness = vi.hoisted(() => ({ + checkConflict: vi.fn(), + scanFiles: vi.fn(), + handleFiles: vi.fn(), + queueUpload: vi.fn(), + showHover: vi.fn(), + closeHovers: vi.fn(), + fileStore: { + req: { items: [] }, + selected: [] as number[], + selectedCount: 0, + multiple: false, + preselect: "", + }, +})); + +vi.mock("@/stores/auth", () => ({ + useAuthStore: () => ({ user: null }), +})); +vi.mock("@/stores/clipboard", () => ({ + useClipboardStore: () => ({ items: [], $patch: vi.fn() }), +})); +vi.mock("@/stores/file", () => ({ + useFileStore: () => harness.fileStore, +})); +vi.mock("@/stores/layout", () => ({ + useLayoutStore: () => ({ + currentPrompt: null, + showHover: harness.showHover, + closeHovers: harness.closeHovers, + }), +})); +vi.mock("@/stores/upload", () => ({ + useUploadStore: () => ({ upload: harness.queueUpload }), +})); +vi.mock("@/api", () => ({ + users: {}, + files: {}, +})); +vi.mock("@/utils/constants", () => ({ enableExec: false })); +vi.mock("@/utils/auth", () => ({})); +vi.mock("@/router", () => ({ default: {} })); +vi.mock("@/i18n", () => ({ default: {} })); +vi.mock("@/utils/upload", () => ({ + checkConflict: harness.checkConflict, + scanFiles: harness.scanFiles, + handleFiles: harness.handleFiles, +})); +vi.mock("@/utils/css", () => ({ default: vi.fn() })); +vi.mock("@/components/header/HeaderBar.vue", () => ({ default: {} })); +vi.mock("@/components/header/Action.vue", () => ({ default: {} })); +vi.mock("@/components/Search.vue", () => ({ default: {} })); +vi.mock("@/components/files/ListingItem.vue", () => ({ default: {} })); +vi.mock("@/components/ContextMenu.vue", () => ({ default: {} })); +vi.mock("vue-router", () => ({ + useRoute: () => ({ path: "/files/target/" }), + onBeforeRouteUpdate: vi.fn(), +})); +vi.mock("vue-i18n", async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }), +})); +vi.mock("pinia", async (importOriginal) => ({ + ...(await importOriginal()), + storeToRefs: () => ({ req: { value: harness.fileStore.req } }), +})); +vi.mock("vue", async (importOriginal) => ({ + ...(await importOriginal()), + inject: () => vi.fn(), + watch: vi.fn(), + onMounted: vi.fn(), + onBeforeUnmount: vi.fn(), + useSSRContext: () => ({ modules: new Set() }), +})); + +const file = (name: string): UploadEntry => ({ + file: { name, size: 12, type: "text/plain" } as File, + name, + size: 12, + isDir: false, +}); + +const conflict = ( + index: number, + checked: Array<"origin" | "dest"> +): ConflictingResource => ({ + index, + name: `/target/file-${index}.txt`, + origin: { size: 12 }, + dest: { size: 10 }, + checked, + isSmallerOnServer: true, +}); + +function setup(component: any) { + return component.setup({}, { expose: vi.fn() }); +} + +function confirmPrompt(result: ConflictingResource[]) { + const prompt = harness.showHover.mock.calls[0][0]; + prompt.confirm({ preventDefault: vi.fn() }, result); +} + +async function runActualHandleFiles() { + const [files, path, overwrite] = harness.handleFiles.mock.calls[0]; + const actual = + await vi.importActual("@/utils/upload"); + actual.handleFiles(files, path, overwrite); +} + +function uploadFlags() { + return harness.queueUpload.mock.calls.map((call) => call[3]); +} + +describe("upload conflict resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + harness.fileStore.preselect = ""; + vi.stubGlobal("window", { + innerWidth: 1024, + innerHeight: 768, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal("document", { + getElementsByClassName: () => [], + }); + }); + + it("authorizes an explicit drag/drop Replace choice", async () => { + const upload = file("file-0.txt"); + harness.scanFiles.mockResolvedValue([upload]); + harness.checkConflict.mockResolvedValue([conflict(0, ["origin"])]); + const bindings = setup(FileListing); + + await bindings.drop({ + preventDefault: vi.fn(), + dataTransfer: { files: [upload.file], items: [] }, + target: { + classList: { contains: () => false }, + parentElement: null, + }, + }); + confirmPrompt([conflict(0, ["origin"])]); + await runActualHandleFiles(); + + expect(uploadFlags()).toEqual([true]); + }); + + it("keeps the forbidden both-selected upload overwrite-safe", async () => { + const upload = file("file-0.txt"); + harness.scanFiles.mockResolvedValue([upload]); + harness.checkConflict.mockResolvedValue([conflict(0, ["origin"])]); + const bindings = setup(FileListing); + + await bindings.drop({ + preventDefault: vi.fn(), + dataTransfer: { files: [upload.file], items: [] }, + target: { + classList: { contains: () => false }, + parentElement: null, + }, + }); + confirmPrompt([conflict(0, ["origin", "dest"])]); + await runActualHandleFiles(); + + expect(uploadFlags()).toEqual([false]); + }); + + it("applies Replace only to the selected file in a mixed batch", async () => { + const conflicting = file("file-0.txt"); + const nonconflicting = file("new-file.txt"); + harness.checkConflict.mockResolvedValue([conflict(0, ["origin"])]); + const bindings = setup(FileListing); + + await bindings.uploadInput({ + currentTarget: { files: [conflicting.file, nonconflicting.file] }, + }); + confirmPrompt([conflict(0, ["origin"])]); + await runActualHandleFiles(); + + expect(uploadFlags()).toEqual([true, false]); + }); + + it("keeps a nonconflicting sibling protected after Skip", async () => { + const conflicting = file("file-0.txt"); + const nonconflicting = file("late-file.txt"); + harness.checkConflict.mockResolvedValue([conflict(0, ["origin"])]); + const bindings = setup(FileListing); + + await bindings.uploadInput({ + currentTarget: { files: [conflicting.file, nonconflicting.file] }, + }); + confirmPrompt([conflict(0, ["dest"])]); + await runActualHandleFiles(); + + expect(harness.queueUpload).toHaveBeenCalledTimes(1); + expect(harness.queueUpload).toHaveBeenCalledWith( + "/files/target/late-file.txt", + "late-file.txt", + nonconflicting.file, + false, + "text" + ); + }); + + it("matches the Upload prompt per-file overwrite semantics", async () => { + const conflicting = file("file-0.txt"); + const nonconflicting = file("new-file.txt"); + harness.checkConflict.mockResolvedValue([conflict(0, ["origin"])]); + const bindings = setup(UploadPrompt); + + await bindings.uploadInput({ + currentTarget: { files: [conflicting.file, nonconflicting.file] }, + }); + confirmPrompt([conflict(0, ["origin"])]); + await runActualHandleFiles(); + + expect(uploadFlags()).toEqual([true, false]); + }); +}); From b21b1245ae1b57f031b2f5d787f32a17532402c0 Mon Sep 17 00:00:00 2001 From: superafun Date: Sat, 25 Jul 2026 13:30:02 +0800 Subject: [PATCH 04/40] fix: accessibility and security improvements (#6033) --- frontend/public/index.html | 2 +- frontend/src/App.vue | 16 ++++++++++++++++ frontend/src/components/files/ExtendedImage.vue | 6 ++++++ frontend/src/components/files/ListingItem.vue | 3 ++- frontend/src/components/header/HeaderBar.vue | 2 +- frontend/src/components/prompts/FileList.vue | 2 +- frontend/src/router/index.ts | 16 ++++++---------- frontend/src/views/Share.vue | 6 +++++- http/static.go | 2 ++ 9 files changed, 40 insertions(+), 15 deletions(-) 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..d18233eb1e0 100644 --- a/frontend/src/components/files/ListingItem.vue +++ b/frontend/src/components/files/ListingItem.vue @@ -18,7 +18,7 @@ :data-dir="isDir" :data-type="type" :aria-label="name" - :aria-selected="isSelected" + :aria-pressed="isSelected" :data-ext="getExtension(name).toLowerCase()" @contextmenu="contextMenu" > @@ -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 @@