Compare commits

..
Author SHA1 Message Date
Graham Steffaniak 79bbda72dd Sidebar link name fixes (#2945) 2026-09-10 19:15:54 -05:00
Graham Steffaniak 8493fe582b updated changelog 2026-09-10 18:42:54 -05:00
Graham Steffaniak a615ba7814 updated with token change for backwards compatiblity (#2944) 2026-09-10 18:30:21 -05:00
Graham Steffaniak a2fb87a79c updated changelog 2026-09-10 18:08:51 -05:00
Aditya Raj Singh b9679d03a3 Fall back to buffered copies when FUSE rejects fast paths (#2938) 2026-09-10 18:08:03 -05:00
Aditya Raj Singh 4b9f03ad35 Preserve deleted sidebar links across restarts (#2935) 2026-09-10 18:05:44 -05:00
Aditya Raj Singh 4b465f5855 Support non-ASCII share passwords (#2933) 2026-09-10 18:00:37 -05:00
Graham Steffaniak 0d68ad5d7c merge changelog 2026-09-10 17:56:59 -05:00
Graham Steffaniak d417da2b83 Merge branch 'main' into dev/v2.0.6 2026-09-10 17:55:47 -05:00
Graham Steffaniak ba04611565 remove direct download tokens (#2943) 2026-09-10 17:54:53 -05:00
Kevin B. 0a2265980e scope padding to listing view (#2934) 2026-09-10 14:11:14 -05:00
Vladimir Babin 1d08a4549b Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2928) 2026-09-09 12:41:25 -05:00
Graham SteffaniakandKevin B. b12add4317 Beta/v2.0.5 (#2916)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-09-07 12:55:40 -05:00
Graham Steffaniak 5610cdd2bf fix onlyoffice csp security regression (#2876) (#2900) 2026-09-03 20:31:53 -05:00
Ethan Lee Qi Yang ea85c50946 fix(contributing): typo from improtant to important (#2873) 2026-08-30 08:48:32 -05:00
be47c7a1e7 Beta/v2.0.3 (#2870)
Co-authored-by: Aditya Raj Singh <github@adityaraj.com>
Co-authored-by: tbsagdic <86909795+tbsagdic@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:53:51 -05:00
609e1d6482 Beta/v2.0.2 (#2845)
Co-authored-by: yzxcj797 <54314860+yzxcj797@users.noreply.github.com>
Co-authored-by: yzxcj797 <yzxcj797@users.noreply.github.com>
2026-08-24 19:33:27 -05:00
3c8c02414e Beta/v2.0.1 (#2800)
Co-authored-by: Mattijs Bliek <mattijs@mattijsbliek.com>
Co-authored-by: GammelSami <39372285+GammelSami@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-15 11:06:07 -05:00
Graham Steffaniak 29f9055db9 Update README.md (#2748) 2026-08-07 16:25:25 -05:00
+10 3c02eedca6 Beta/v2.0.0 (#2746)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Gabriel Górski <me@glaeqen.eu>
Co-authored-by: Joel Godfrey <82655889+BaccanoMob@users.noreply.github.com>
Co-authored-by: Shane Chiu <71911511+pwn2ooown@users.noreply.github.com>
Co-authored-by: carloda <35763669+carloda@users.noreply.github.com>
Co-authored-by: Taufik Hidayat <taufikdev88@gmail.com>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: SIDDHU SINGH <siddhu3116@gmail.com>
Co-authored-by: Gaurav Kalal <117557391+Gorupa@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
Co-authored-by: Anatolii Prylutskyi <anpryl@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Graham Steffaniak <graham@debian-lake.ghome.net>
Co-authored-by: Neil <kylelzzz02@icloud.com>
Co-authored-by: Graham <info@quantumx-apps.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:24:38 -05:00
Graham Steffaniak 8c5c92bef0 fixed webdav api token creation bug (#2740) (#2744) 2026-08-07 13:53:36 -05:00
b6c9cc9762 Beta/v1.5.4 (#2705)
Co-authored-by: Vadym <63876858+myUdav4iik@users.noreply.github.com>
Co-authored-by: e-tang <eric@tyo.com.au>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 12:39:44 -05:00
42c71c75cb Beta/v1.5.3 (#2700)
Co-authored-by: Vadym <63876858+myUdav4iik@users.noreply.github.com>
Co-authored-by: e-tang <eric@tyo.com.au>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 10:29:56 -05:00
Graham SteffaniakandKevin B. 9503b532e1 Beta/v1.5.2 (#2645)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-07-17 20:09:28 -05:00
Graham Steffaniak a825ddd301 Update README.md (#2646) 2026-07-17 19:08:18 -05:00
da085bca69 Beta/v1.5.1 (#2630)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Graham <info@quantumx-apps.com>
2026-07-13 11:51:51 -05:00
Stephan Paternotte 64250ffff0 Update nl.json (#2603) 2026-07-05 08:18:28 -05:00
Graham Steffaniak 0bc8889c00 Update README.md (#2570) 2026-06-23 10:18:23 -05:00
6eaead073a Beta/v1.5.0 (#2546)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-06-19 11:17:57 -05:00
Graham Steffaniak 826b060bb1 Beta/v1.4.4 (#2536) 2026-06-11 10:52:01 -05:00
21dfc6169e Beta/v1.4.3 (#2530)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Graham Steffaniak <graham@debian-lake.ghome.net>
Co-authored-by: Neil <kylelzzz02@icloud.com>
Co-authored-by: Graham <info@quantumx-apps.com>
Co-authored-by: Anatolii Prylutskyi <anpryl@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:36:10 -05:00
4708faec5f fix(preview): keep opened file selected after closing its preview (#2515)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-06-09 14:48:04 -05:00
fa5abc8c67 Beta/v1.4.2 (#2485)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-22 11:14:27 -05:00
837 changed files with 85091 additions and 32705 deletions

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
"version": "22"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
+6 -1
View File
@@ -1,10 +1,15 @@
backend/**.db
backend/*.sqlite
backend/*.sqlite.*
**/*.sqlite
**/*.sqlite.*
backend/filebrowser.sqlite*
**.crt
**.key
backend/tmp
backend/dist
backend/backend*
backend/http/dist/**
backend/internal/web/dist/**
backend/vendor/**
frontend/dist/**
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
create-dev-branch:
name: Create Dev Branch
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Check out repository
+6 -1
View File
@@ -5,10 +5,13 @@ on:
branches:
- "main"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -17,6 +20,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
+10 -8
View File
@@ -11,7 +11,7 @@ on:
jobs:
prime_docker_cache:
name: Prime docker cache
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -28,11 +28,13 @@ jobs:
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
build_frontend:
name: Build frontend
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- name: Build frontend
working-directory: frontend
run: npm i && npm run build
@@ -40,10 +42,10 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: dist
path: backend/http/dist
path: backend/internal/web/dist
build_backend:
name: Build backend
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -61,7 +63,7 @@ jobs:
test_playwright:
needs: [build_backend, build_frontend, prime_docker_cache]
name: Test Playwright - ${{ matrix.name }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
strategy:
fail-fast: false
matrix:
@@ -103,8 +105,8 @@ jobs:
run: chmod +x backend/filebrowser
- name: Copy frontend build to backend
run: |
mkdir -p backend/http/embed
cp -r frontend/dist/* backend/http/embed
mkdir -p backend/internal/web/embed
cp -r frontend/dist/* backend/internal/web/embed
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
@@ -124,7 +126,7 @@ jobs:
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
push_pr_to_registry:
name: Build docker image
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -1,88 +0,0 @@
name: Promote Beta to Stable
on:
workflow_dispatch:
inputs:
version:
description: "Version to promote (format: 0.0.0)"
required: true
type: string
jobs:
promote-beta-to-stable:
name: Promote Beta to Stable
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }} # Uses Personal Access Token for branch operations
- name: Validate version format
id: validate_version
run: |
VERSION="${{ github.event.inputs.version }}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ ERROR: Version must be in '0.0.0' format."
exit 1
fi
echo "✅ Version format validated: $VERSION"
- name: Check if beta branch exists
id: check_beta_branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
if ! git ls-remote --exit-code origin "$BETA_BRANCH"; then
echo "❌ ERROR: Beta branch '$BETA_BRANCH' does not exist!"
exit 1
fi
echo "✅ Beta branch '$BETA_BRANCH' exists."
- name: Check if stable branch already exists
id: check_stable_branch
run: |
VERSION="${{ github.event.inputs.version }}"
STABLE_BRANCH="stable/v$VERSION"
if git ls-remote --exit-code origin "$STABLE_BRANCH"; then
echo "❌ ERROR: Stable branch '$STABLE_BRANCH' already exists!"
exit 1
fi
echo "✅ Stable branch '$STABLE_BRANCH' does not exist."
- name: Create and push new stable branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
STABLE_BRANCH="stable/v$VERSION"
# Fetch latest changes
git fetch origin "$BETA_BRANCH"
# Create new stable branch from beta branch
git checkout -b "$STABLE_BRANCH" origin/"$BETA_BRANCH"
# Push new stable branch to remote
git push origin "$STABLE_BRANCH"
echo "✅ Successfully created stable branch '$STABLE_BRANCH' from '$BETA_BRANCH'."
- name: Merge beta into stable
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
STABLE_BRANCH="stable/v$VERSION"
# Checkout stable branch
git checkout "$STABLE_BRANCH"
# Merge beta into stable
git merge --no-ff "origin/$BETA_BRANCH" -m "Merge $BETA_BRANCH into $STABLE_BRANCH"
# Push merge changes
git push origin "$STABLE_BRANCH"
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
promote-dev-to-beta:
name: Promote Dev to Beta
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Check out repository
+25 -13
View File
@@ -7,7 +7,7 @@ on:
jobs:
test-backend:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -16,19 +16,16 @@ jobs:
- working-directory: backend
run: go test -race -v ./... -timeout 30s
lint-backend:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- uses: golangci/golangci-lint-action@v7
with:
version: 'v2.1.6'
working-directory: backend
install-mode: goinstall
- working-directory: backend
run: go tool golangci-lint run --path-prefix=backend
format-backend:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -37,32 +34,47 @@ jobs:
- working-directory: backend
run: go fmt ./...
lint-frontend:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run lint
test-frontend:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run test
check-translations:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run i18n:check
check-docs:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- run: make generate-docs
- run: git diff --exit-code
- run: git diff --exit-code
check-icons:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run icons:check
-66
View File
@@ -1,66 +0,0 @@
name: beta release
on:
push:
branches:
- "beta/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: true
make_latest: false
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
-66
View File
@@ -1,66 +0,0 @@
name: stable release
on:
push:
branches:
- "stable/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go build -o filebrowser .
- name: Build
uses: docker/build-push-action@v6
with:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_release_tag:
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false
make_latest: true
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
+3 -1
View File
@@ -11,12 +11,14 @@ permissions:
jobs:
update_tag:
name: Update Release tag
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- name: Install dependencies and build frontend
run: npm i && npm run build
working-directory: frontend
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
publish_playwright_base:
name: Publish playwright-base to GHCR
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
permissions:
contents: read
packages: write
+7 -3
View File
@@ -1,6 +1,8 @@
*.db
*.db.old
*.sqlite
# Playwright settings migration fixture (BoltDB → SQLite tests + Docker settings image)
!_docker/src/settings/backend/database.db.old
*.sqlite**
*.bak
*.log
*.mjs
@@ -21,6 +23,8 @@ frontend/tsconfig.tsbuildinfo
/frontend/playwright-files
/frontend/screenshots
/frontend/generated
frontend/.venv-icons
frontend/.tmp-icons-*
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/frontend/sharePrepStorage.json
@@ -29,8 +33,8 @@ frontend/tsconfig.tsbuildinfo
/backend/*test_config.yaml
/backend/generated.yaml
/backend/srv
/backend/http/dist
/backend/http/embed
/backend/internal/web/dist
/backend/internal/web/embed
/backend/debug
.DS_Store
+262
View File
@@ -2,6 +2,268 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v2.0.6
**Security**:
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
**Notes**:
- renaming sources updates sidebar links (#2878)
- disabling/deleting a user source removes that source from sidebar links (#2942)
**Bugfixes**:
- Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2894)
- Support non-ASCII share passwords (#2933)
- Fall back to buffered copies when FUSE rejects fast paths (#2938) (#2924)
- Preserve deleted sidebar links across restarts (#2935)
## v2.0.5
**New Features**:
- Users can set default view mode and thumbnail size from Profile settings (Listing options). Admins can set the same defaults for existing users from the user edit prompt. Values persist to the account and apply on new browsers or devices; they do not override view preferences already saved locally ([#2884](https://github.com/gtsteffaniak/filebrowser/issues/2884)).
- Admins can edit all profile preference defaults for existing users from the user management panel (listing, preview, sidebar, theme, and related options), matching what was previously available only for new users and global user defaults.
**Security**:
- [Moderate] public metadata api returns file content to anonymous share visitors, ignoring the share's download limit and file-viewer setting (GHSA-55mw-cwg7-m8f5) -- thanks @kta1kri
**Bugfixes**:
- Fixed infinite loading spinner on `/` and `/login` after CSP security hardening ([#2886](https://github.com/gtsteffaniak/filebrowser/issues/2886), [#2890](https://github.com/gtsteffaniak/filebrowser/issues/2890))
- fixed darkmode styling bug where background wouldn't respect configured settings when not logged in
- fixed styling bug where padding was missing from right side of listing view.
- tilde paths for sources were not properly expanded. (eg "~" for home)
- fixed tooltip not showing up for some circumstances on mobile, improved consistency.
- a few styling fixes for inconsistencies (#2908)
- Disk-usage widget no longer reports PB/TB-scale garbage on Docker Desktop for Mac (virtiofs) bind mounts; space is computed from the filesystem fragment size (`f_frsize`) instead of the optimal-transfer block size (`f_bsize`) on Linux ([#2894](https://github.com/gtsteffaniak/filebrowser/issues/2894)).
## v2.0.4
**Bugfixes**:
- OnlyOffice editor failed to load after the CSP security fix; allow configured `integrations.office.url` and `integrations.office.internalUrl` origins in `script-src` ([#2874](https://github.com/gtsteffaniak/filebrowser/issues/2874)).
## v2.0.3
**Security**:
- [High] Stored XSS via HTML preview: strip `<script>` from sanitized srcdoc, remove `allow-same-origin` from the preview iframe sandbox, set HttpOnly on the session cookie, and add a nonce-based `script-src` CSP on the SPA shell (GHSA-vvm6-jwrf-hgmg) -- thanks @qrn12580
- [Moderate] Non-admin users could PATCH privileged fields on their own account (scopes, permissions, etc.), allowing privilege escalation; restores v1 non-admin field guard on PATCH /api/users (GHSA-p5cc-4p84-c4m2) -- thanks @Chri6s
**New Features**:
- Added option to globally disable the "Install App" message via `frontend.disablePWAInstall`
- PWA improvements for installed mobile apps: dedicated maskable icons (192/512), manifest and splash colors that follow the instance default theme, runtime `theme-color` sync on dark-mode toggle, and edge-to-edge safe-area layout for notched devices (#2625) (#2869) -- thanks @APatenaude
**Bugfixes**:
- Fixed Fuji `.raf` thumbnail preview by extracting the camera-embedded JPEG from the RAF header (regression for files where TIFF-based raw extraction does not apply).
- Image previews for unsupported decodable formats now return HTTP 415 immediately instead of attempting JPEG decode and returning HTTP 500.
- OIDC: preserve the verified ID-token user identifier when falling back to the UserInfo endpoint for missing groups, so login no longer fails with HTTP 500 when UserInfo returns groups but omits the configured identifier.
- LDAP: restore `memberOf` as the default `groupsClaim` when unset, fixing group-based admin and login authorization for existing LDAP configs after the v2.0.2 default changed to `groups`.
- "Install app" message reappears after being cleared on device.
- Restored upload chunk size `0` to disable chunking as documented ([#2202](https://github.com/gtsteffaniak/filebrowser/issues/2202)).
- Fixed iOS 26 / WebKit multi-chunk upload stall by isolating chunk connections and returning partial chunk JSON responses ([#2734](https://github.com/gtsteffaniak/filebrowser/issues/2734)).
- Sidebar folder links with custom names, icons, or styles no longer disappear after restart; multiple shortcuts to different folders on the same source are preserved ([#2809](https://github.com/gtsteffaniak/filebrowser/issues/2809)).
- Adding a source to a user via scopes now auto-adds a matching sidebar link; removing a source keeps the link (shown disabled) so users can delete it manually.
- A password reset via CLI returns user to a password method user.
- Authentication login methods are consistently enforced without modifying existing account settings.
## v2.0.2
**Bugfixes**:
- OIDC: `groupsClaim` value is always included in requested scopes and falls back to the UserInfo endpoint when the ID token omits the groups claim.
- Windows: fix backslash duplication in navigation URLs, folder sizes showing 4 KB, and download/preview failures (#2815, #2816)
- Wrong extension in the 'new database was created popup (#2817) (#2824)
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
- OnlyOffice is inaccessible on password-protected shares (#2811)
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
## v2.0.1
**Security**:
- [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds
- [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo
- [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu)
- [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec
- [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209
**Notes**:
- GroupMap `SyncUserGroups` fix so JWT/OIDC/LDAP group memberships survive restart (#2742).
- Session renew is handled by client keep-alive. removed per-request `X-Renew-Token` header handling.
- Improvements to document thumbnail generation performance.
- behavior changes for typing to select files in listing view.
- next/previous buttons don't hide automatically on photos (#2767)
- added more actions available in advanced search (#2776)
**Bugfixes**:
- GroupMap mutations (`SyncUserGroups`, add/remove group members) are write-through to SQL so JWT/OIDC/LDAP group memberships survive restart (#2742).
- `defaultEnabled` behavior change/fix so defaultEnabled means its always added to users on startup and login. (updated docs too)
- Long uploads/downloads no longer lose the session mid-transfer: proactive session JWT keep-alive renews before expiry (#2638).
- Disable auto logout/session expiry if there are active file transfers going (#2638)
- upload options get cut off on mobile (#2685)
- Uploading image: image gets cut-off (#2765)
- Process aborts (not just 500) during PDF thumbnail generation (#2763)
- PDF preview generation prevents file uploads from completing (#2752)
- Brings back double-tap to zoom images that was mistakenly removed.
- multiple embedded subtitles with same language bug (#2756)
- gallery view download button missing (#2767)
- fixed HEIC rotation regression from v1.5.x
- fixed raw image preview regression from v1.5.x
- html viewer takes full height
- Members without download permission receive 403 when opening text-based files despite OnlyOffice preview being enabled (#2777)
## v2.0.0
This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary, and the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert legacy config.
**Breaking Changes**:
- Removed: `GET /api/raw` and `GET /public/api/raw` download routes — use `/api/resources/download` instead.
- Removed: `/share/…` URL redirect to `/public/share/…` — use `/public/share/…` directly.
- Removed: singular `source` search api param (use `sources`), bare `scope` paths without `sourceName:` prefix, and `glob` / `useGlob` aliases (use `useWildcard`).
- Removed `config.conditionals`, source-level `indexingIntervalMinutes` (indexing always uses adaptive scheduling), and deprecated rule fields `fileNames` / `folderNames` / top-level `hidden` — use `config.rules` with `fileName`, `folderName`, and `ignoreHidden` on rules. See [Exclusion rules](https://filebrowserquantum.com/en/docs/user-guides/general-configuration/exclusion-rules/).
- Removed: deprecated `userDefaults` config formats (nested and flat) — use the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert before upgrading.
- Changed: `PUT /api/users` moved to the more appropriate `PATCH` method and requires specifying `which` in the body. Blank or `all` values are rejected.
- Changed: http related config options in `server` config key moved to `http` config key. See [HTTP settings](https://filebrowserquantum.com/en/docs/configuration/http/).
- Changed (reverse proxy): `http.trustedHeaders` (v1.5.x list) removed — use `http.trustProxyHeaders: true` when behind nginx, Traefik, or Caddy. When enabled, FileBrowser honors `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-For`, and `X-Real-IP` for client IP, cookies, OIDC callbacks, WebAuthn, share URLs, rate limiting, and activity logs. Default is `false` (direct connection values). The [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) converts v1 `trustedHeaders` lists to `trustProxyHeaders: true`. See [Reverse proxy](https://filebrowserquantum.com/en/docs/getting-started/reverse-proxy/) and [HTTP trustProxyHeaders](https://filebrowserquantum.com/en/docs/configuration/http/#trustproxyheaders).
- Changed: `FILEBROWSER_DATABASE` environment variable — use `FILEBROWSER_DATABASE_PATH` instead. See [Environment variables](https://filebrowserquantum.com/en/docs/reference/environment-variables/) and [Server settings](https://filebrowserquantum.com/en/docs/configuration/server/).
- Changed: Moved stream api to `/api/media/stream`. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
- Changed: CLI user management — canonical commands are `user set <username> --password [value]` and `user promote <username>`; `set -u username,password` is deprecated. See [CLI reference](https://filebrowserquantum.com/en/docs/reference/cli/).
**New Features**:
- View grant mechanism to distinguish between UI viewing and download. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
- granular per-source file permissions (view, download, modify, create, delete) with automatic migration from global permissions
- per-source defaults configurable in `settings > access management`
- `view` permission is automatically set to true unless explicitly set to false. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
- can be enforced for all users. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
- New activity logs for user activity.
- charts and historical data
- export to csv reports
- Media player improvements:
- Refreshed playback queue UI: Supports thumbnails, stored into session storage, and has a "clear queue" button (#2575) (#2600).
- Loop now has 3 states (off/single/all) and neither of them will clear the existing queue (#2600).
- New "Audio visualizer" for audio files (desktop-only), you can configure some basic things to your taste (#2575) (#2620).
- The current state of the audio panel now is stored into local storage.
- More gestures: Swipe up to enter/exit fullscreen, long-press to change playback speed, single tap to pause (#2575).
- Videos now will resume fullscreen and PiP when navigating (queue auto-navigation, swipes gestures or next/previous) (#2649).
- Added `F4` shortcut to refresh the current directory and metadata (#2600).
- opt-in feature to send deployment analytics to filebrowser quantum developer servers
- anonymized with a viewer so users can see what info would be sent.
- if opt-in, every month a snapshot of your deployment config would be sent to developer servers
- this will help me know what features are being used and what versions everyone is on over time. I will also provide a public dashboard with this information in the future.
- WebDAV now supports set modification time via the `X-OC-Mtime` header for clients that support it (#2626). See [WebDAV docs](https://filebrowserquantum.com/en/docs/features/webdav/).
- Copy operations now preserve their original modification times (#2642) (#2647):
- WebUI preserves both, files and directories.
- WebDAV `COPY` preserves modification times only for files, is limitation we have with webdav.
- User default enhancements
- Config `userDefaults` seeds SQLite on first run; only fields explicitly set in config stay locked in **Settings → User defaults** (other defaults remain editable)
- Added administrator controls for universal user defaults and enforced preferences in `settings > user management > user defaults`.
- Added configurable default file permissions per source in `settings > access management`.
- Added a User Defaults editor for account, permission, and profile preferences in the edit/create user prompt. See [User management](https://filebrowserquantum.com/en/docs/configuration/users/).
- Database env var rename: `FILEBROWSER_DATABASE` is removed (startup fails if set). Use `FILEBROWSER_DATABASE_PATH` (default `filebrowser.sqlite`) or `server.database.path` in config. See [Environment variables](https://filebrowserquantum.com/en/docs/reference/environment-variables/) and [Server settings](https://filebrowserquantum.com/en/docs/configuration/server/).
- CLI: `user set` with `--password` (inline value, interactive prompt on TTY, or piped stdin); `user promote` for admin grant without password reset. See [CLI reference](https://filebrowserquantum.com/en/docs/reference/cli/).
**Notes**:
- v2.x.x uses a new write-through backend state management. Changes go through a fast memory layer and also write changes to database to stay in sync. See [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/).
- CLI server start (`./filebrowser`), `setup`, `version`, and `set rule` syntax unchanged; see [CLI docs](https://filebrowserquantum.com/en/docs/reference/cli/)
- new dropdown and input styles
- swipe gestures to dismiss notifications (#2672)
- user updates are more granular, don't include entire user payload.
- `user.id` has been moved to a backend property and all frontend apis now query users by username. Swagger has been updated. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
- removed legacy and deprecated properties from API responses and generated config output
- `/api/media/stream` is audio/video only (range-based chunking). Non-media inline viewing uses `GET /api/resources/view`. Both endpoints use the same `viewToken` from file metadata. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
- removed exiftool as an optional helper, always built with the supported libraries (requires 64 bit os)
- If migration issues arise, see [Migration troubleshooting](https://filebrowserquantum.com/en/docs/getting-started/migration/troubleshooting/).
- default browser media player option removed, always uses themed plyr
- [docker] upgraded ffmpeg from 8.1.2 to 9.0
## v1.5.5
**BugFixes**:
- fix uncustomized (minimal) API tokens creation needed by webdav clients (#2503)
## v1.5.4
**Notes**:
- PWA installation name now capped at 30 characters instead of 12 (#2699)
**BugFixes**:
- fix forward slash blocked in text fields when the search shortcut listener intercepts `/` (#2696)
## v1.5.3
**BugFixes**:
- fix probe canShare with the real file, not a fixed text/plain stand-in (#2664)
- When the logout button is pressed, the user is redirected to an invalid URL that does not honor baseURL / externalUrl (#2657)
- fix redirect to login when an authenticated request returns 401 (expired session)
## v1.5.2
**Notes**:
- [docker] upgraded ffmpeg version 8.1.1 to 8.1.2
**BugFixes**:
- Fixed OnlyOffice reopening an older editor state after a document is modified (#2633) (#2578).
- Cannot "Reset and generate new two-factor code" to reset the TOTP for a user (#2399) (#2641).
## v1.5.1
**BugFixes**:
- fix http config section being dropped so trustedHeaders and disableRateLimit apply (#2602) (#2560)
- fix upload shares with password (#2589) (#2573)
- fix token when returning from preview on shares with pass (#2588) (#2465)
- fix share undefined url after editing (#2567) (#2523)
## v1.5.0
**New Features**:
- Added basic html viewer with relative reference support (#2522)
- Shares can have pinned files, requires a user to have edit access to share (#2522)
- Enhanced search
- now uses "lazy" match by default. (#2509)
- added missing `case sensitive` option in the UI
- Progressive Web App (PWA) improvements
- restored install prompt with sidebar install button (#2086)
- camera and video capture buttons on upload (mobile-friendly `capture` inputs)
- send files to other apps via the Web Share API (`Send to app` in the context menu)
- App notifications for file operations (#2478)
- optional browser notifications when uploads, chunked downloads, move/copy, or failures finish while the tab is in the background
- single on/off toggle in Notifications settings, stored in browser local storage
**Notes**:
- [docker] upgraded ffmpeg version 8.1 to 8.1.1
**BugFixes**:
- fixed PWA manifest `scope` and `id` so install works when the app is served under a base URL
- installing a public share as a PWA now opens the share URL instead of the site root (#2302)
## v1.4.4
**BugFixes**:
- Pinning/Unpinning file resets users' scope permission (#2532)
## v1.4.3
**Security**:
- [High] Path traversal in subtitle handler allows any authenticated user to read arbitrary files (GHSA-vvp7-h4fj-m28w)
**New Features**:
- pinned files/folders (#2396) (#2510)
- Markdown Image relative reference support (#2355)
- Add an option to create a new folder for unarchiving (#2338)
**Notes**:
- added fallback to show text in notification if copy fails (#2517)
- added `alt`+`arrow up` shortcut as alias of `backspace` to go into parent directory (#2501) (#2521)
- added `alt`+`arrow down` shortcut as alias of `enter` to open files in Listing View (#2501) (#2521)
- updated help menu with better translations
- migrate i18n to v11 (#2472) (#2504)
- migrate eslint (#2459)
- improved preview cancellation to improve performance when navigating UI.
**BugFixes**:
- fix lyrics regex parsing (#2505)
- fix folder previews issue (#2487) (#2492)
- fix accidental exit on images while using gestures (#2508)
- navigating using the up/down arrow keys in Listing View was "jumping randomly" in some view modes (#2521)
- socket field in config.yaml is ignored (#2497)
- fix keep opened file selected after closing its preview @anpryl (#2515)
## v1.4.2
**Security**:
+9 -70
View File
@@ -5,13 +5,13 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
## Prerequisites
- **Go 1.25+** (see `backend/go.mod`)
- **Node.js 20.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Docker** (optional, for containerized development)
- **Git** especially improtant on windows -- needed for bash support.
- **Git** especially important on Windows -- needed for bash support.
### Optional Tools
- **ffmpeg**: For media features (subtitles, thumbnails, heic)
- **ffmpeg/ffprobe**: For media features (subtitles, thumbnails, heic)
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
@@ -42,11 +42,12 @@ make dev
### Backend (Go)
- **Entry Point**: `backend/main.go` → `backend/cmd/`
- **HTTP Server**: `backend/http/` - API routes, middleware, auth
- **Storage**: BoltDB via `backend/database/storage/`
- **Authentication**: Multiple providers in `backend/auth/`
- **Indexing**: Real-time search in `backend/indexing/`
- **Previews**: Image/video/document generation in `backend/preview/`
- **HTTP Server**: `backend/internal/web/` - API routes, middleware, auth
- **Storage**: SQLite via `backend/internal/database/`
- **Authentication**: Multiple providers in `backend/internal/auth/`
- **Indexing**: Real-time search in `backend/pkg/indexing/`
- **Previews**: Image/video/document generation in `backend/internal/preview/`
- **Activity**: Query, recording, and export logic in `backend/internal/activity/`
### Frontend (Vue.js + TypeScript)
- **Framework**: Vue 3 + Vite + TypeScript
@@ -90,18 +91,6 @@ make test-playwright # E2E tests in Docker
E2E tests run with three authentication modes: standard auth, no auth, and proxy auth.
### Coverage & Performance
```bash
cd backend
./run_check_coverage.sh # Coverage report with HTML output
./run_benchmark.sh # Benchmarks
```
**Code Coverage:**
- View report: Open `backend/coverage.html` after running coverage script
- CI enforces coverage for critical packages
- Use `go test -cover` for quick package coverage
## Code Standards
### Backend (Go)
@@ -156,53 +145,3 @@ type(scope): description
Types: feat, fix, docs, refactor, test, chore
```
## Roadmap
Check the [Project Roadmap](https://github.com/users/gtsteffaniak/projects/4/views/2) to see issues sorted by priority. This helps you understand what features are planned and where you can contribute most effectively.
## Troubleshooting
### Common Issues
**Build failures:**
```bash
# Frontend
cd frontend && rm -rf node_modules && npm install
# Backend
cd backend && go mod tidy && go clean -modcache
```
**Authentication Issues:**
Always first enable debug logging in your config file if you have issues.
1. **OIDC Login Fails:**
- Check redirect URLs match your config
- Verify OIDC provider settings
- Enable debug logs to see auth flow
- Common issue: mismatched callback URLs
2. **Proxy Auth Not Working:**
- Verify header names match config
- Check nginx/reverse proxy passes headers
- Test with: `curl -H "X-Auth: username" localhost:8080`
- Enable `--log-level debug` to see headers
3. **TOTP Issues:**
- Ensure server time is synchronized
- Check QR code generation in logs
- Test with authenticator app time settings
- Database might have stale TOTP secrets
4. **Session/Cookie Problems:**
- Clear browser cookies and localStorage
- Verify cookie domain settings
- Try incognito/private browsing mode
### Getting Help
- **Wiki**: [Project Wiki](https://github.com/gtsteffaniak/filebrowser/wiki)
- **Issues**: [GitHub Issues](https://github.com/gtsteffaniak/filebrowser/issues)
- **PR Template**: See `.github/PULL_REQUEST_TEMPLATE.md`
+16 -17
View File
@@ -1,6 +1,5 @@
<div align="center">
[![Go Report Card](https://goreportcard.com/badge/github.com/gtsteffaniak/filebrowser/backend)](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/0b548794f2ac4871a0cf7aa9ecab049f)](https://app.codacy.com/gh/gtsteffaniak/filebrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
[![latest version](https://img.shields.io/github/v/release/gtsteffaniak/filebrowser)](https://github.com/gtsteffaniak/filebrowser/releases)
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
@@ -8,19 +7,19 @@
[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A#is-there-a-way-to-donate-or-support-this-project)
<img width="150" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" title="Logo">
<img width="150" alt="FileBrowser Quantum logo" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174">
<h3>FileBrowser Quantum</h3>
The best free self-hosted web-based file manager.
<br/><br/>
<img width="800" src="https://github.com/user-attachments/assets/162d7a95-33b7-49bd-976c-dd6822c0d22b">
<img width="800" alt="FileBrowser Quantum file listing in dark mode" src="https://filebrowserquantum.com/images/generated/listing/view-mode-normal-dark.jpg">
</div>
## Pinned
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
## About
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has a modern responsive interface that has many advanced features to manage users, access, sharing, and file preview and editing.
@@ -39,21 +38,23 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
2. ✅ Login support for OIDC, LDAP, JWT, password + 2FA, and proxy.
3. ✅ Beautiful, Responsive, and Customizable user interface.
4. ✅ Streamlined configuration via `config.yaml` config file.
5. ✅ Ultra-efficient Sqlite [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing)
5. ✅ Efficient search
- Real-time search results as you type.
- Real-time monitoring and updates in the UI.
- Search supports file and folder sizes, along with various filters.
6. ✅ Better listing browsing
- Better thumbnail support including **office**, **video**, and **album artwork**, and **3D models**.
- Thumbnails support includes **office**, **video**, and **album artwork**, and **3D models**.
- Faster and more responsive views with animations.
- Folder sizes are displayed and support thumbnails
- **Folder sizes** are displayed and support thumbnails
- Navigating remembers the last scroll position.
- WebDAV support
- Granular permissions
7. ✅ Highly configurable and [customizable sharing options](https://filebrowserquantum.com/en/docs/shares/options/)
- share expiration time
- users who can access share (including anonymous)
- styling and themes
- file viewing, editing, and uploading permissions
8. ✅ Directory-level access control that can be scoped to user or group.
8. ✅ Access control that can be scoped to user or group and source path.
9. ✅ Developer API support
- Ability to create long-lived API Tokens.
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
@@ -68,24 +69,22 @@ FileBrowser Quantum differs significantly from the original version. Many of the
The UI has a simple three-component navigation system:
1. (Left) Multi-action button with slide-out panel.
2. (Middle) The powerful search bar.
3. (Right) The view change toggle.
2. (Middle) The powerful search bar / title
3. (Right) The view change toggle / overflow menu
All other functions are moved either into the action menu or pop-up menus.
If the action does not depend on context, it will exist in the slide-out
action panel. If the action is available based on context, it will show up as
a pop-up menu.
<p align="center">
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
</p>
<img width="1200" height="750" alt="Aug-07-2026 14-54-55" src="https://github.com/user-attachments/assets/fd1c1747-f30f-4e21-b991-f45744416d7e" />
## Official Docs
See the [Official Docs](https://filebrowserquantum.com/). Contributions are welcome and encouraged! See [FilebrowserDocs Github](https://github.com/quantumx-apps/filebrowserDocs).
## Comparison Chart
Application Name | <img width="48" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
Application Name | <img width="48" alt="" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" alt="" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" alt="" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" alt="" src="https://cdn.iconscout.com/icon/free/png-256/free-google-drive-logo-icon-svg-download-png-2476481.png" > Google_Drive | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
@@ -125,10 +124,10 @@ Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
activity log | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
+13 -12
View File
@@ -1,33 +1,35 @@
FROM gtstef/ffmpeg:8.1-decode AS ffmpeg
FROM gtstef/ffmpeg:9.0-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev
RUN go mod download && go run ./scripts/setup-gofitz-cgo
ENV CGO_ENABLED=1
RUN go build -tags mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:jod-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
WORKDIR /build
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN cd frontend && npm ci --maxsockets 1
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
FROM alpine:latest
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
@@ -36,12 +38,11 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]
RUN [ "exiftool", "-ver" ]
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+4 -5
View File
@@ -9,15 +9,14 @@ COPY [ "./_docker/src/general/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# generate duplicate 1.1MB files
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
RUN ./filebrowser user set testuser1 --password testuser1 && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+2 -2
View File
@@ -5,8 +5,8 @@ WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
+14 -6
View File
@@ -2,13 +2,21 @@ FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
COPY [ "./_docker/src/settings/backend/config.yaml", "./"]
COPY [ "./_docker/src/settings/backend/database.db.old", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
ENTRYPOINT [ "./filebrowser" ]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser user set testuser1 --password testuser1
RUN ./filebrowser set rule -s access -p / -r user -v admin --allow -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded/showme.txt -r user -v admin --allow -c config.yaml
RUN ./filebrowser set rule -s access -p /denied -r user -v admin -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
ENTRYPOINT [ "./filebrowser" ]
+3 -3
View File
@@ -9,9 +9,9 @@ COPY [ "./_docker/src/no-config/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+2 -2
View File
@@ -10,8 +10,8 @@ COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+2 -2
View File
@@ -4,10 +4,10 @@ COPY [ "./_docker/src/oidc/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
RUN mkdir -p ./http/dist
RUN mkdir -p ./internal/web/dist
COPY [ "./_docker/src/oidc/backend/", "./"]
COPY ["./backend/filebrowser", "./"]
COPY [ "./backend/http/embed/", "./http/dist/"]
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
WORKDIR /app/frontend
+2 -2
View File
@@ -9,8 +9,8 @@ COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+2 -2
View File
@@ -5,8 +5,8 @@ WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
+6 -5
View File
@@ -2,14 +2,15 @@ FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
COPY [ "./frontend/tests", "./tests" ]
COPY [ "./_docker/src/screenshots/frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./backend/filebrowser", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
+6 -6
View File
@@ -10,16 +10,16 @@ COPY [ "./frontend/tests/", "./tests" ]
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./backend/filebrowser", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser set rule -s access -p / -r user -v admin -allow -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded/showme.txt -r user -v admin -allow -c config.yaml
RUN ./filebrowser user set testuser1 --password testuser1
RUN ./filebrowser set rule -s access -p / -r user -v admin --allow -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded/showme.txt -r user -v admin --allow -c config.yaml
RUN ./filebrowser set rule -s access -p /denied -r user -v admin -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+3 -3
View File
@@ -9,9 +9,9 @@ COPY [ "./_docker/src/sharing/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+10 -9
View File
@@ -4,23 +4,24 @@ ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
RUN go build -tags analytics -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=${REVISION}'" \
-o filebrowser .
FROM node:jod-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
WORKDIR /build
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN npm i
COPY ./frontend/ ./
RUN cd frontend && npm ci
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl
@@ -31,7 +32,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
# exposing default port for auto discovery.
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- ../frontend/generated:/app/frontend/generated
entrypoint: ["/bin/sh", "-c", "./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots && npx playwright test --project light-screenshots"]
build:
dockerfile: ./_docker/Dockerfile.playwright-local
dockerfile: ./_docker/Dockerfile.playwright-screenshots
context: ../
nginx-proxy-auth:
image: nginx
+5 -4
View File
@@ -1,5 +1,7 @@
server:
<<: *server-main
http:
<<: *http-main
frontend:
<<: *frontend-main
@@ -12,7 +14,6 @@ userDefaults:
models: true
office: true
highQuality: true
permissions:
download: true
create: true
share: true
account:
permissions:
share: true
@@ -1,6 +1,7 @@
server-main: &server-main
http-main: &http-main
port: 80
baseURL: "/"
server-main: &server-main
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
@@ -9,6 +10,11 @@ server-main: &server-main
name: "playwright + files"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: true
create: true
- path: "."
name: "docker"
config:
+15 -11
View File
@@ -1,5 +1,6 @@
server:
http:
port: 8080
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
@@ -8,6 +9,11 @@ server:
config:
defaultEnabled: true
createUserDir: true
defaultPermissions:
view: true
download: true
modify: true
create: true
frontend:
name: "Graham's Filebrowser"
@@ -31,19 +37,17 @@ auth:
groupsClaim: "mygroup"
adminGroup: "admin"
userDefaults:
ui:
darkMode: true
account:
disableSettings: false
permissions:
share: true
listing:
singleClick: false
preview:
image: true
popup: true
video: true
office: true
highQuality: true
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: true
api: false
download: true
create: true
+17 -16
View File
@@ -1,6 +1,7 @@
server:
http:
port: 80
baseURL: "/files/"
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
@@ -9,6 +10,11 @@ server:
name: "exclude"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: true
create: true
rules:
- folderPath: "excluded"
- folderPath: "excludedButVisible"
@@ -28,16 +34,13 @@ server:
- filePath: "/subfolderExclusions/exclusionlist.sh"
- folderPath: "/subfolderExclusions/folderItem"
viewable: true
conditionals:
rules:
- fileStartsWith: "startsWith"
- folderEndsWith: "Folder"
- folderNames: 'folderNames'
- fileNames: 'fileNames'
- folderName: 'folderName'
- fileName: 'fileName'
- folderName: '.hiddenDir'
- fileName: '.hidden'
- fileStartsWith: "startsWith"
- folderEndsWith: "Folder"
- folderName: "folderName"
- fileName: "fileName"
- fileName: "fileNames"
- folderName: ".hiddenDir"
- fileName: ".hidden"
auth:
methods:
@@ -54,13 +57,11 @@ frontend:
disableEventThemes: true
userDefaults:
showHidden: true
listing:
showHidden: true
preview:
image: true
popup: true
video: true
office: true
highQuality: true
permissions:
download: true
create: true
highQuality: true
+15 -10
View File
@@ -7,6 +7,11 @@ server:
config:
defaultEnabled: true
createUserDir: true
defaultPermissions:
view: true
download: true
modify: true
create: true
frontend:
name: "Graham's Filebrowser"
@@ -36,13 +41,13 @@ userDefaults:
video: true
office: true
highQuality: true
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: false
api: false
download: true
create: true
ui:
darkMode: true
listing:
singleClick: false
account:
disableSettings: false
permissions:
admin: false
share: false
api: false
+10 -5
View File
@@ -1,11 +1,18 @@
server:
http:
port: 80
baseURL: "/"
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultPermissions:
view: true
download: true
modify: true
create: true
auth:
methods:
@@ -22,7 +29,8 @@ frontend:
disableEventThemes: true
userDefaults:
showHidden: true
listing:
showHidden: true
preview:
image: true
popup: true
@@ -31,6 +39,3 @@ userDefaults:
office: true
models: true
highQuality: true
permissions:
download: true
create: true
+12 -18
View File
@@ -1,6 +1,8 @@
server:
http:
port: 8080
baseURL: "/subpath"
baseURL: "/subpath"
trustProxyHeaders: true
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
@@ -9,6 +11,11 @@ server:
config:
defaultEnabled: true
createUserDir: true
defaultPermissions:
view: true
download: true
modify: true
create: true
frontend:
name: "Graham's Filebrowser"
@@ -30,19 +37,6 @@ auth:
logoutRedirectUrl: "https://localhost/api/auth/logout2"
userDefaults:
preview:
image: true
popup: true
video: true
office: true
highQuality: true
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: true
api: false
download: true
create: true
account:
permissions:
share: true
+2 -1
View File
@@ -1,6 +1,7 @@
server:
http:
port: 8080
baseURL: "/files/"
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
/**
* Read environment variables from file.
@@ -9,7 +10,7 @@ import { defineConfig, devices } from "@playwright/test";
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
export default defineConfig<PlaywrightFixtureOptions>({
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/screenshots",
+11 -2
View File
@@ -1,6 +1,9 @@
server:
http:
port: 80
baseURL: "/"
server:
database:
migrateFrom: "database.db.old"
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
@@ -9,6 +12,8 @@ server:
name: "playwright + files"
config:
defaultEnabled: true
defaultPermissions:
modify: false
- path: "."
name: "docker"
config:
@@ -38,4 +43,8 @@ frontend:
auth:
methods:
password:
signup: true
signup: true
userDefaults:
listing:
showHidden: true
Binary file not shown.
+2 -1
View File
@@ -1,6 +1,7 @@
server:
http:
port: 80
baseURL: "/"
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
+4 -3
View File
@@ -2,11 +2,12 @@ root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=version'\" ."
bin = "tmp/filebrowser"
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
entrypoint = "./tmp/filebrowser"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs", "preview/thumbnails", "preview/heic"]
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs"]
log = "air.log"
delay = 1000 # ms
+5 -4
View File
@@ -2,11 +2,12 @@ root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/filebrowser.exe --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=version'\" ."
bin = "tmp/filebrowser.exe"
args_bin = ["-c", "test_config.yaml"]
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
cmd = "go build -o ./tmp/filebrowser.exe --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
entrypoint = "./tmp/filebrowser.exe"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser.exe -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "http\\dist", "http\\embed", "swagger\\docs"]
exclude_dir = ["tmp", "vendor", "internal\\web\\dist", "internal\\web\\embed", "internal\\preview\\testdata", "internal\\preview\\thumbnails", "internal\\preview\\heic", "swagger\\docs"]
exclude_unchanged = true
follow_symlink = false
poll = true
+18 -3
View File
@@ -2,6 +2,11 @@
project_name: filebrowser
version: 2
before:
hooks:
- go mod download
- go run ./scripts/setup-gofitz-cgo
builds:
# Build configuration for linux amd64
- id: linux-amd64
@@ -9,10 +14,11 @@ builds:
- CGO_ENABLED=1
- CC=zig cc -target x86_64-linux-musl
tags:
- analytics
- mupdf
- musl
ldflags: &ldflags
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/common/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA={{ .ShortCommit }}
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
goos:
@@ -32,6 +38,7 @@ builds:
- CGO_ENABLED=1
- CC=zig cc -target aarch64-linux-musl
tags:
- analytics
- mupdf
- musl
goos:
@@ -47,6 +54,8 @@ builds:
ldflags: *ldflags
main: main.go
binary: filebrowser
tags:
- analytics
goos:
- linux
goarch:
@@ -62,8 +71,10 @@ builds:
- id: windows
env:
- CGO_ENABLED=1
- CC=zig cc -target x86_64-windows
# windows-gnu avoids zig rejecting Go's -tsaware when cross-compiling from macOS
- CC=zig cc -target x86_64-windows-gnu -lc
tags:
- analytics
- mupdf
ldflags: *ldflags
main: main.go
@@ -81,16 +92,20 @@ builds:
ldflags: *ldflags
main: main.go
binary: filebrowser
tags:
- analytics
goos:
- freebsd
goarch:
- amd64
# Build configuration for macos without upx
# Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported)
- id: macos
ldflags: *ldflags
main: main.go
binary: filebrowser
tags:
- analytics
goos:
- darwin
goarch:
@@ -1,62 +0,0 @@
package fileutils
import (
"os"
"testing"
)
func TestUnixModeToFileMode_setgid(t *testing.T) {
m := unixModeToFileMode(0o2770)
if got := m.Perm(); got != 0o770 {
t.Fatalf("Perm() = %#o, want 0770", got)
}
if m&os.ModeSetgid == 0 {
t.Fatal("expected ModeSetgid")
}
if m&os.ModeSetuid != 0 || m&os.ModeSticky != 0 {
t.Fatalf("unexpected special bits: %#o", m)
}
}
func TestCommonPrefix(t *testing.T) {
testCases := map[string]struct {
paths []string
want string
}{
"same lvl": {
paths: []string{
"/home/user/file1",
"/home/user/file2",
},
want: "/home/user",
},
"sub folder": {
paths: []string{
"/home/user/folder",
"/home/user/folder/file",
},
want: "/home/user/folder",
},
"relative path": {
paths: []string{
"/home/user/folder",
"/home/user/folder/../folder2",
},
want: "/home/user",
},
"no common path": {
paths: []string{
"/home/user/folder",
"/etc/file",
},
want: "",
},
}
for name, tt := range testCases {
t.Run(name, func(t *testing.T) {
if got := CommonPrefix('/', tt.paths...); got != tt.want {
t.Errorf("CommonPrefix() = %v, want %v", got, tt.want)
}
})
}
}
-121
View File
@@ -1,121 +0,0 @@
package auth
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// JSONAuth is a json implementation of an Auther.
type JSONAuth struct {
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
DisableOtp bool `json:"disableOtp" yaml:"disableOtp"`
}
// Auth authenticates the user via a json in content body.
func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.User, error) {
username := r.URL.Query().Get("username")
recaptcha := r.URL.Query().Get("recaptcha")
password := r.Header.Get("X-Password")
totpCode := r.Header.Get("X-Secret")
// URL-decode password to support special characters in headers
password, err := url.QueryUnescape(password)
if err != nil {
return nil, fmt.Errorf("invalid password encoding: %v", err)
}
// If ReCaptcha is enabled, check the code.
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
ok, err := auther.ReCaptcha.Ok(recaptcha) //nolint:govet
if err != nil {
return nil, err
}
if !ok {
return nil, os.ErrPermission
}
}
user, getErr := userStore.Get(username)
var passwordHash string
if getErr != nil {
passwordHash = utils.InvalidPasswordHash
} else {
passwordHash = user.Password
}
// always run checkPwd to prevent timing attacks
err = utils.CheckPwd(password, passwordHash)
if err != nil {
return nil, err
}
if getErr != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
if user.TOTPSecret != "" && !auther.DisableOtp {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
}
err = VerifyTotpCode(user, totpCode, userStore)
if err != nil {
return nil, err
}
}
if user.LoginMethod != users.LoginMethodPassword {
return nil, errors.ErrWrongLoginMethod
}
return user, nil
}
const reCaptchaAPI = "/recaptcha/api/siteverify"
// ReCaptcha identifies a recaptcha connection.
type ReCaptcha struct {
Host string `json:"host"`
Key string `json:"key"`
Secret string `json:"secret"`
}
// Ok checks if a reCaptcha responde is correct.
func (r *ReCaptcha) Ok(response string) (bool, error) {
body := url.Values{}
body.Set("secret", r.Secret)
body.Add("response", response)
client := &http.Client{}
resp, err := client.Post(
r.Host+reCaptchaAPI,
"application/x-www-form-urlencoded",
strings.NewReader(body.Encode()),
)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, nil
}
var data struct {
Success bool `json:"success"`
}
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
return false, err
}
return data.Success, nil
}
-18
View File
@@ -1,18 +0,0 @@
package auth
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodNoAuth is used to identify no auth.
const MethodNoAuth = "noauth"
// NoAuth is no auth implementation of auther.
type NoAuth struct{}
// Auth uses authenticates user 1.
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return usr.Get(uint(1))
}
-28
View File
@@ -1,28 +0,0 @@
package auth
import (
"net/http"
"os"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// MethodProxyAuth is used to identify no auth.
const MethodProxyAuth = "proxy"
// ProxyAuth is a proxy implementation of an auther.
type ProxyAuth struct {
Header string `json:"header"`
}
// Auth authenticates the user via an HTTP header.
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
username := r.Header.Get(a.Header)
user, err := usr.Get(username)
if err == errors.ErrNotExist {
return nil, os.ErrPermission
}
return user, err
}
+14 -41
View File
@@ -4,27 +4,23 @@ import (
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
// validateAccessRules migrates old-style access rules (without trailing slashes) to new format
func validateAccessRules() {
if store.Access == nil {
return
}
// Get all sources
for sourcePath := range settings.Config.Server.SourceMap {
// Get all rules for this source
rules, err := store.Access.GetAllRules(sourcePath)
rules, err := state.GetAllRules(sourcePath)
if err != nil {
logger.Errorf("Failed to get access rules for source %s: %v", sourcePath, err)
continue
}
// Check if there are any rules that need migration
needsMigration := false
for oldPath := range rules {
if oldPath != "/" && !strings.HasSuffix(oldPath, "/") {
@@ -39,78 +35,55 @@ func validateAccessRules() {
migratedCount := 0
for oldPath, rule := range rules {
// Check if this path needs migration (doesn't have trailing slash and isn't root)
if oldPath != "/" && !strings.HasSuffix(oldPath, "/") {
// Create the new path with trailing slash
newPath := oldPath + "/"
// Migrate the rule to the new path
if err := migrateAccessRule(sourcePath, oldPath, newPath, rule); err != nil {
if err := migrateAccessRule(sourcePath, newPath, rule); err != nil {
logger.Errorf("Failed to migrate rule from %s to %s: %v", oldPath, newPath, err)
continue
}
// Remove the old rule
if err := removeOldAccessRule(sourcePath, oldPath); err != nil {
logger.Errorf("Failed to remove old rule %s: %v", oldPath, err)
continue
}
state.RemoveRuleByPathKey(sourcePath, oldPath)
migratedCount++
}
}
// After migration, clear cache
if migratedCount > 0 {
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
}
}
}
// migrateAccessRule creates a new access rule with the new path format
func migrateAccessRule(sourcePath, oldPath, newPath string, rule access.FrontendAccessRule) error {
// Add deny users
func migrateAccessRule(sourcePath, newPath string, rule access.FrontendAccessRule) error {
for _, user := range rule.Deny.Users {
if err := store.Access.DenyUser(sourcePath, newPath, user); err != nil && err != errors.ErrExist {
if err := state.DenyUser(sourcePath, utils.IndexPathFromNormalized(newPath, true), user); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add deny user %s: %w", user, err)
}
}
// Add deny groups
for _, group := range rule.Deny.Groups {
if err := store.Access.DenyGroup(sourcePath, newPath, group); err != nil && err != errors.ErrExist {
if err := state.DenyGroup(sourcePath, utils.IndexPathFromNormalized(newPath, true), group); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add deny group %s: %w", group, err)
}
}
// Add allow users
for _, user := range rule.Allow.Users {
if err := store.Access.AllowUser(sourcePath, newPath, user); err != nil && err != errors.ErrExist {
if err := state.AllowUser(sourcePath, utils.IndexPathFromNormalized(newPath, true), user); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add allow user %s: %w", user, err)
}
}
// Add allow groups
for _, group := range rule.Allow.Groups {
if err := store.Access.AllowGroup(sourcePath, newPath, group); err != nil && err != errors.ErrExist {
if err := state.AllowGroup(sourcePath, utils.IndexPathFromNormalized(newPath, true), group); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add allow group %s: %w", group, err)
}
}
// Add deny all rule if needed
if rule.DenyAll {
if err := store.Access.DenyAll(sourcePath, newPath); err != nil && err != errors.ErrExist {
if err := state.DenyAll(sourcePath, utils.IndexPathFromNormalized(newPath, true)); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add deny all rule: %w", err)
}
}
return nil
}
// removeOldAccessRule removes the old access rule by directly accessing the internal storage
func removeOldAccessRule(sourcePath, oldPath string) error {
// Access the internal storage directly to remove the old rule
// We need to use the unnormalized path since that's how it was stored originally
store.Access.RemoveRuleByPath(sourcePath, oldPath)
return nil
}
+224
View File
@@ -0,0 +1,224 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
func TestAdminHasSharePermissionAfterPlaywrightStartup(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
sharingConfig := "../../_docker/src/sharing/backend/config.yaml"
settings.Initialize(sharingConfig)
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
_, err := state.Initialize(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
t.Cleanup(func() { _ = state.Close() })
validateUserInfo(true)
admin, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if !admin.Permissions.Share {
t.Fatalf("admin Share=false after validateUserInfo; perms=%+v", admin.Permissions)
}
if !admin.Permissions.Admin {
t.Fatalf("admin Admin=false; perms=%+v", admin.Permissions)
}
_ = state.Close()
_, err = state.Initialize(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
admin, err = state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if !admin.Permissions.Share {
t.Fatalf("admin Share=false after DB reload; perms=%+v", admin.Permissions)
}
}
func TestAdminHasSharePermissionAfterSettingsMigration(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
alignSettingsSourcesForMigrationFixture(t)
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
_, err := state.Initialize(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
t.Cleanup(func() { _ = state.Close() })
validateUserInfo(false)
admin, err := state.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if !admin.Permissions.Share {
t.Fatalf("admin Share=false after settings migration; perms=%+v", admin.Permissions)
}
assertSourceSidebarLinkCount(t, &admin, 3)
}
func TestGrahamGainsAccessAfterSettingsMigrationStartup_dockerSourcePaths(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
configPath := filepath.Join(t.TempDir(), "config.yaml")
configYAML := `server:
sources:
- path: "/app/frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
- path: "/app/backend"
name: "docker"
config:
defaultEnabled: true
- path: "/tests/playwright-files"
name: "access"
config:
defaultEnabled: true
denyByDefault: true
auth:
methods:
password:
enabled: true
`
if err := os.WriteFile(configPath, []byte(configYAML), 0o600); err != nil {
t.Fatal(err)
}
settings.Initialize(configPath)
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
_, err := state.Initialize(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
t.Cleanup(func() { _ = state.Close() })
validateUserInfo(false)
graham, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
foundScope = true
break
}
}
if !foundScope {
t.Fatalf("graham should gain access scope after validateUserInfo: %#v", graham.BackendScopes)
}
foundLink := false
for _, link := range graham.SidebarLinks {
if link.Name == "access" || link.SourceName == fixtureAccessSource {
foundLink = true
break
}
}
if !foundLink {
t.Fatalf("graham should have access sidebar link after validateUserInfo: %#v", graham.SidebarLinks)
}
}
func TestGrahamGainsAccessAfterSettingsMigrationStartup(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
alignSettingsSourcesForMigrationFixture(t)
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
_, err := state.Initialize(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(state.Default())
t.Cleanup(func() { _ = state.Close() })
validateUserInfo(false)
graham, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
foundScope = true
break
}
}
if !foundScope {
t.Fatalf("graham should gain access scope after validateUserInfo: %#v", graham.BackendScopes)
}
foundLink := false
for _, link := range graham.SidebarLinks {
if link.Name == "access" || link.SourceName == fixtureAccessSource {
foundLink = true
break
}
}
if !foundLink {
t.Fatalf("graham should have access sidebar link after validateUserInfo: %#v", graham.SidebarLinks)
}
if len(graham.SidebarLinks) == 0 {
t.Fatal("graham should have sidebar links after migration so frontend does not fall back to all sources")
}
}
func assertSourceSidebarLinkCount(t *testing.T, user *users.User, want int) {
t.Helper()
count := 0
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
count++
}
}
if count != want {
t.Fatalf("user %q has %d source sidebar links, want %d: %v",
user.Username, count, want, user.SidebarLinks)
}
}
+101 -215
View File
@@ -2,7 +2,6 @@ package cmd
import (
"bufio"
"flag"
"fmt"
"os"
"path/filepath"
@@ -10,11 +9,12 @@ import (
"strings"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -22,169 +22,44 @@ var (
configPath string
)
// return bool to indicate if the program should continue running
func runCLI() (bool, bool) {
generateYaml()
// Global flags
var help bool
// Override the default usage output to use generalUsage()
flag.Usage = generalUsage
flag.StringVar(&configPath, "c", "", "Path to the config file, default: config.yaml")
flag.BoolVar(&help, "h", false, "Get help about commands")
if configPath == "" {
configPath = os.Getenv("FILEBROWSER_CONFIG")
// backwards compatibility in docker, prefer config.yaml if it exists
if configPath != "" {
_, err := os.Stat(configPath)
if err != nil {
logger.Fatalf("config file %v does not exist, please create it or set the FILEBROWSER_CONFIG environment variable to a valid config file path", configPath)
}
} else {
configPath = "config.yaml"
}
}
// Parse global flags (before subcommands)
flag.Parse() // print generalUsage on error
// Show help if requested
if help {
generalUsage()
return false, false
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, dbConfig string
var asAdmin bool
var sourceName, indexPath, ruleCategory, value string
var allow bool
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
setCmd.BoolVar(&asAdmin, "a", false, "Create a user as admin user, used in combination with -u")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
setCmd.StringVar(&sourceName, "s", "", "source name to apply the rule to")
setCmd.StringVar(&indexPath, "p", "", "Index path (e.g. /secret) for rule command")
setCmd.StringVar(&ruleCategory, "r", "", "Rule category: 'user', 'group', or 'all' (for deny only)")
setCmd.StringVar(&value, "v", "", "Value: username or groupname (not required if ruleCategory is 'all')")
setCmd.BoolVar(&allow, "allow", false, "Allow access (default: false, which means deny)")
storeCfg := configPath
cliSubcmd := ""
var setKind string
if len(os.Args) > 1 {
switch os.Args[1] {
case "version":
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return false, false
case "setup":
cliSubcmd = "setup"
case "set":
cliSubcmd = "set"
if len(os.Args) < 3 {
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
os.Exit(1)
}
setKind = os.Args[2]
var err error
// Handle old syntax: "set -u user,pass -c config.yaml"
if setKind == "-u" {
err = setCmd.Parse(os.Args[2:])
} else {
// New pattern: subcommand-based (e.g., "set user" or "set rule")
err = setCmd.Parse(os.Args[3:])
}
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
storeCfg = dbConfig
}
}
switch cliSubcmd {
case "setup":
createConfig(configPath)
return false, false
case "set":
if setKind == "-u" {
dbExists := getStore(storeCfg)
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
err := setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false, dbExists
}
switch setKind {
case "rule":
dbExists := getStore(storeCfg)
sourceInfo, ok := settings.Config.Server.NameToSource[sourceName] // backend source is path
if !ok {
fmt.Printf("error: invalid source name: %s\n", sourceName)
os.Exit(1)
}
err := setRule(dbConfig, sourceInfo.Path, indexPath, ruleCategory, value, allow)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false, dbExists
default:
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", setKind)
os.Exit(1)
}
return false, false
default:
dbExists := getStore(storeCfg)
return true, dbExists
}
}
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
// Validate required parameters
if fsPath == "" {
return fmt.Errorf("real filesystem path is required: use -f <fsPath>")
func setRule(backendSourcePath, indexPath, ruleCategory, value string, allow bool) error {
if backendSourcePath == "" {
return fmt.Errorf("backend source path is missing; check --source / -s")
}
if indexPath == "" {
return fmt.Errorf("index path is required: use -p <indexPath>")
return fmt.Errorf("--path / -p is required (index path within the source, e.g. /)")
}
if ruleCategory == "" {
return fmt.Errorf("ruleCategory is required: use -r <user|group|all>")
return fmt.Errorf("role is required: use --role / -r <user|group|all>")
}
if ruleCategory != "all" && value == "" {
return fmt.Errorf("value is required when ruleCategory is 'user' or 'group': use -v <username|groupname>")
return fmt.Errorf("value is required when role is 'user' or 'group': use --value / -v <username|groupname>")
}
// Apply the rule based on allow flag and ruleCategory
var err error
parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true)
if err != nil {
return err
}
if allow {
switch ruleCategory {
case "user":
err = store.Access.AllowUser(fsPath, indexPath, value)
err = state.AllowUser(backendSourcePath, parsedPath, value)
case "group":
err = store.Access.AllowGroup(fsPath, indexPath, value)
err = state.AllowGroup(backendSourcePath, parsedPath, value)
default:
return fmt.Errorf("invalid ruleCategory for allow: must be 'user' or 'group'")
return fmt.Errorf("invalid role for allow: must be 'user' or 'group'")
}
} else {
switch ruleCategory {
case "user":
err = store.Access.DenyUser(fsPath, indexPath, value)
err = state.DenyUser(backendSourcePath, parsedPath, value)
case "group":
err = store.Access.DenyGroup(fsPath, indexPath, value)
err = state.DenyGroup(backendSourcePath, parsedPath, value)
case "all":
err = store.Access.DenyAll(fsPath, indexPath)
err = state.DenyAll(backendSourcePath, parsedPath)
default:
return fmt.Errorf("invalid ruleCategory: must be 'user', 'group', or 'all'")
return fmt.Errorf("invalid role: must be 'user', 'group', or 'all'")
}
}
@@ -197,42 +72,31 @@ func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool
action = "allow"
}
if ruleCategory == "all" {
fmt.Printf("successfully added %s rule for all users on index path '%s' in filesystem path '%s'\n", action, indexPath, fsPath)
fmt.Printf("successfully added %s rule for all users on index path '%s' in filesystem path '%s'\n", action, indexPath, backendSourcePath)
} else {
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, fsPath)
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, backendSourcePath)
}
return nil
}
func setUser(dbConfig string, asAdmin bool) error {
if len(os.Args) < 4 {
return fmt.Errorf("missing username and password for 'set user'. Use 'set -u username,password'")
}
userInfo := strings.Split(os.Args[3], ",")
if len(userInfo) < 2 {
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", userInfo)
}
username := userInfo[0]
password := userInfo[1]
user, err := store.Users.Get(username)
func setUser(username, password string, asAdmin bool) error {
user, err := state.GetUserByUsername(username)
if err != nil {
newUser := users.User{
Username: username,
LoginMethod: users.LoginMethodPassword,
NonAdminEditable: users.NonAdminEditable{
Password: password,
FrontendUser: users.FrontendUser{
Username: username,
LoginMethod: users.LoginMethodPassword,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Path,
newUser.BackendScopes = append(newUser.BackendScopes, users.BackendScope{
Path: source.Path,
Scope: source.Config.DefaultUserScope,
})
}
}
// Create the user logic
if asAdmin {
logger.Infof("Creating user as admin: %s\n", username)
} else {
@@ -240,28 +104,29 @@ func setUser(dbConfig string, asAdmin bool) error {
}
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Account.Permissions)
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser, newUser.Permissions)
err = state.CreateUser(&newUser, password)
if err != nil {
return fmt.Errorf("could not create user: %v", err)
}
if dirErr := files.MakeUserDirs(&newUser, true); dirErr != nil {
logger.Error(dirErr.Error())
}
fmt.Printf("successfully created user")
return nil
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
logger.Infof("Converting user %s from %s to password login method", username, user.LoginMethod)
}
user.Password = password
user.TOTPSecret = "" // reset TOTP secret if it exists
user.TOTPNonce = "" // reset TOTP nonce if it exists
user.TOTPSecret = ""
user.TOTPNonce = ""
user.LoginMethod = users.LoginMethodPassword
if asAdmin {
user.Permissions.Admin = true
}
// Ensure version is set for existing users being updated
if user.Version == 0 {
user.Version = users.CurrentUserMigrationVersion
}
err = store.Users.Save(user, true, false)
err = state.UpdateUser(&user, password, "password", "totpSecret", "totpNonce", "loginMethod", "permissions", "version")
if err != nil {
return fmt.Errorf("could not update user: %v", err)
}
@@ -269,8 +134,30 @@ func setUser(dbConfig string, asAdmin bool) error {
return nil
}
// askQuestion displays a prompt and reads a line of input from the user.
// It returns the defaultValue if the user's input is empty.
func promoteUser(username string) error {
user, err := state.GetUserByUsername(username)
if err != nil {
return fmt.Errorf("user %s not found", username)
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s cannot be promoted: only password-login users can be granted admin via CLI", username)
}
if user.Permissions.Admin {
fmt.Printf("user %s is already an admin\n", username)
return nil
}
user.Permissions.Admin = true
if user.Version == 0 {
user.Version = users.CurrentUserMigrationVersion
}
err = state.UpdateUser(&user, "", "permissions")
if err != nil {
return fmt.Errorf("could not promote user: %v", err)
}
fmt.Printf("successfully promoted user to admin: %s\n", username)
return nil
}
func askQuestion(reader *bufio.Reader, prompt string, defaultValue string) string {
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
input, _ := reader.ReadString('\n')
@@ -281,9 +168,6 @@ func askQuestion(reader *bufio.Reader, prompt string, defaultValue string) strin
return input
}
// askYesNoQuestion prompts the user with a yes/no question and returns a boolean.
// It retries until valid input ("yes", "y", "no", "n") is received.
// The defaultValue must be "yes" or "no".
func askYesNoQuestion(reader *bufio.Reader, prompt string, defaultValue string) bool {
for {
answer := askQuestion(reader, prompt, defaultValue)
@@ -298,43 +182,45 @@ func askYesNoQuestion(reader *bufio.Reader, prompt string, defaultValue string)
}
}
// createConfig orchestrates the configuration process by asking the user a series of questions.
func createConfig(configpath string) {
func createConfig(configpath string, noInput bool) error {
if noInput {
return fmt.Errorf("'setup' requires interactive input; rerun without --no-input")
}
reader := bufio.NewReader(os.Stdin)
config := settings.SetDefaults(false)
config.Server.Sources = []*settings.Source{{Path: "./"}}
if configpath == "" {
configpath = "config.yaml"
}
fmt.Println("--- Starting Configuration Setup ---")
realPath := ""
// 1. Ask for the source filesystem path (with validation)
for {
config.Server.Sources[0].Path = askQuestion(reader, "What is the source filesystem path?", "./")
// Convert relative path to absolute path
absolutePath, err := filepath.Abs(config.Server.Sources[0].Path)
if err == nil {
var isDir bool
// Resolve symlinks and get the real path
realPath, isDir, _ = iteminfo.ResolveSymlinks(absolutePath)
if realPath != "" && isDir {
break // Valid path found, exit loop
break
}
}
fmt.Printf("Error: The path '%s' does not exist or isn't valid. Please try again.\n", config.Server.Sources[0].Path)
}
// 2. Ask for the source name
defaultSourceName := filepath.Base(realPath)
sourceName := askQuestion(reader, "What should the first source name be?", defaultSourceName)
if sourceName != defaultSourceName {
config.Server.Sources[0].Name = sourceName
}
// 3. Ask for server port (with validation)
for {
portStr := askQuestion(reader, "What port should the server listen on?", "80")
port, err := strconv.Atoi(portStr)
if err == nil && (port >= 1 && port <= 65535) {
config.Server.Port = port
break // Port is valid, exit loop
config.Http.Port = port
break
}
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
}
@@ -355,53 +241,54 @@ func createConfig(configpath string) {
}
for {
config.Server.Database = askQuestion(reader, "What should the file name and path be for the database?", "./database.db")
if strings.HasSuffix(config.Server.Database, ".db") {
break // Valid path found, exit loop
// "database.db" is the reserved legacy-bolt filename and is rejected
// at startup (see backend/cmd/migrate.go), so the wizard must not
// suggest it as the default.
config.Server.DatabaseV2.Path = askQuestion(reader, "What should the file name and path be for the database?", "./filebrowser.sqlite")
if strings.HasSuffix(config.Server.DatabaseV2.Path, ".sqlite") {
break
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .db", config.Server.Database)
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
}
// 4. Ask for the application brand name
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
// 5. Ask for admin username and password
config.Auth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
config.Auth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
// 6. Ask boolean (Yes/No) questions using the helper
config.UserDefaults.Account.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
modifyDefault := askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.Server.Sources[0].Config.DefaultPermissions = settings.NormalizeSourceFilePermissions(users.SourceFilePermissions{
View: true,
Download: true,
Modify: modifyDefault,
Create: modifyDefault,
Delete: modifyDefault,
})
config.UserDefaults.Account.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares by default?", "no")
fmt.Println("--- Configuration Complete ---")
// marshall yaml and write to file
// Marshal the struct to YAML bytes
yamlData, err := yaml.Marshal(&config)
if err != nil {
return
return fmt.Errorf("marshaling config: %w", err)
}
// Write the YAML data to the file
err = os.WriteFile("config.yaml", yamlData, 0644) // 0644 provides read/write for owner, read for others
err = os.WriteFile(configpath, yamlData, 0600)
if err != nil {
return
return fmt.Errorf("writing config file: %w", err)
}
// cleanup database if it exists
if _, err := os.Stat(config.Server.Database); err == nil {
if _, err := os.Stat(config.Server.DatabaseV2.Path); err == nil {
response := askYesNoQuestion(reader, "Database specified already exists. Move database file to backup to start fresh?", "no")
if !response {
return
return nil
}
// move database file to backup
backupPath := config.Server.Database + ".bak"
err = os.Rename(config.Server.Database, backupPath)
backupPath := config.Server.DatabaseV2.Path + ".bak"
err = os.Rename(config.Server.DatabaseV2.Path, backupPath)
if err != nil {
fmt.Printf("Error moving database file to backup: %v\n", err)
} else {
fmt.Printf("Database file moved to backup: %s\n", backupPath)
}
}
return nil
}
func generateYaml() {
@@ -414,7 +301,6 @@ func generateYaml() {
if generateConfig {
os.Exit(0)
}
}
func SplitByMultiple(str string) []string {
+185
View File
@@ -0,0 +1,185 @@
package cmd
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/alecthomas/kong"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func freshCLI() cliRoot {
return cliRoot{}
}
func newCLIParser(t *testing.T, cli *cliRoot) *kong.Kong {
t.Helper()
parser, err := kong.New(cli, kong.Name("filebrowser"), kong.Bind(&cli.Globals))
require.NoError(t, err)
return parser
}
func TestParseDefaultServerCommand(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{})
require.NoError(t, err)
assert.Equal(t, "run", ctx.Command())
}
func TestParseVersionCommand(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"version"})
require.NoError(t, err)
assert.Equal(t, "version", ctx.Command())
}
func TestParseGlobalConfigFlag(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
_, err := parser.Parse([]string{"-c", "/tmp/custom.yaml", "version"})
require.NoError(t, err)
assert.Equal(t, "/tmp/custom.yaml", cli.Config)
}
func TestParseUserSetWithPasswordFlag(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"user", "set", "alice", "--password", "secret", "--admin"})
require.NoError(t, err)
assert.Equal(t, "user set <username>", ctx.Command())
assert.Equal(t, "alice", cli.User.Set.Username)
assert.True(t, cli.User.Set.Password.Provided)
assert.Equal(t, "secret", cli.User.Set.Password.Inline)
assert.True(t, cli.User.Set.Admin)
}
func TestParseUserSetConfigFlagOrder(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
_, err := parser.Parse([]string{"-c", "other.yaml", "user", "set", "bob", "--password", "x"})
require.NoError(t, err)
assert.Equal(t, "other.yaml", cli.Config)
}
func TestParseSetRuleFlags(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{
"set", "rule",
"-s", "access", "-p", "/", "-r", "user", "-v", "admin", "--allow",
})
require.NoError(t, err)
assert.Equal(t, "set rule", ctx.Command())
assert.Equal(t, "access", cli.Set.Rule.Source)
assert.Equal(t, "/", cli.Set.Rule.Path)
assert.Equal(t, "user", cli.Set.Rule.Role)
assert.Equal(t, "admin", cli.Set.Rule.Value)
assert.True(t, cli.Set.Rule.Allow)
}
func TestParseLegacySetUser(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"set", "-u", "alice,secret", "-a"})
require.NoError(t, err)
assert.Equal(t, "set", ctx.Command())
assert.Equal(t, "alice,secret", cli.Set.User)
assert.True(t, cli.Set.Admin)
}
func TestPasswordFlagResolveInline(t *testing.T) {
p := passwordFlag{Provided: true, Inline: "inline-pass"}
got, err := p.resolve(true)
require.NoError(t, err)
assert.Equal(t, "inline-pass", got)
}
func TestPasswordFlagResolveStdin(t *testing.T) {
r, w, err := os.Pipe()
require.NoError(t, err)
oldStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() { os.Stdin = oldStdin })
_, err = w.WriteString("piped-secret\n")
require.NoError(t, err)
require.NoError(t, w.Close())
p := passwordFlag{Provided: true}
got, err := p.resolve(false)
require.NoError(t, err)
assert.Equal(t, "piped-secret", got)
}
func TestPasswordFlagMissing(t *testing.T) {
p := passwordFlag{}
_, err := p.resolve(false)
require.Error(t, err)
assert.Contains(t, err.Error(), "--password is required")
}
func TestPasswordFlagNoInputRequiresInline(t *testing.T) {
p := passwordFlag{Provided: true}
_, err := p.resolve(true)
require.Error(t, err)
assert.Contains(t, err.Error(), "--no-input")
}
func TestSplitByMultiple(t *testing.T) {
assert.Equal(t, []string{"info", "warning", "error"}, SplitByMultiple("info|warning|error"))
}
func TestSetCmdRunSkipsWhenRuleSelected(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"set", "rule", "-s", "x", "-p", "/", "-r", "all"})
require.NoError(t, err)
cli.Set.User = "should-not-run"
err = cli.Set.Run(ctx)
require.NoError(t, err)
}
func TestSetCmdRunLegacyUser(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"set", "-u", "bad"})
require.NoError(t, err)
err = cli.Set.Run(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "not enough info")
}
func TestPasswordReadAllFromReader(t *testing.T) {
var buf bytes.Buffer
buf.WriteString(" trimmed \n")
data, err := io.ReadAll(&buf)
require.NoError(t, err)
assert.Equal(t, "trimmed", strings.TrimSpace(string(data)))
}
func TestResolveConfigPathEnvMissingFile(t *testing.T) {
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
t.Setenv("FILEBROWSER_CONFIG", missing)
config := ""
resolveConfigPath(&config)
assert.Equal(t, missing, config)
_, err := os.Stat(config)
assert.Error(t, err)
}
func TestSetupNoInputRejected(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"setup", "--no-input"})
require.NoError(t, err)
err = ctx.Run()
require.Error(t, err)
assert.Contains(t, err.Error(), "interactive input")
}
+101
View File
@@ -0,0 +1,101 @@
package cmd
import (
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupCLIUserTest(t *testing.T) {
t.Helper()
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.InitializeCLI("../../../_docker/src/noauth/backend/config.yaml")
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
store, _, err := state.Open(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(store)
t.Cleanup(func() { _ = state.Close() })
}
func TestSetUserConvertsOIDCToPassword(t *testing.T) {
setupCLIUserTest(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatal(err)
}
if err := setUser("graham", "newpassword", false); err != nil {
t.Fatalf("setUser: %v", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if loaded.LoginMethod != users.LoginMethodPassword {
t.Fatalf("loginMethod = %q, want password", loaded.LoginMethod)
}
}
func TestPromoteUserRejectsNonPasswordLoginMethod(t *testing.T) {
setupCLIUserTest(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatal(err)
}
err := promoteUser("graham")
if err == nil {
t.Fatal("promoteUser: expected error for oidc user")
}
if !strings.Contains(err.Error(), "password-login") {
t.Fatalf("promoteUser err = %q, want password-login restriction", err.Error())
}
}
func TestPromoteUserAllowsPasswordLoginMethod(t *testing.T) {
setupCLIUserTest(t)
passwordUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodPassword,
},
}
if err := state.CreateUser(passwordUser, "secret"); err != nil {
t.Fatal(err)
}
if err := promoteUser("graham"); err != nil {
t.Fatalf("promoteUser: %v", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if !loaded.Permissions.Admin {
t.Fatal("expected admin after promote")
}
}
+83
View File
@@ -0,0 +1,83 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
const settingsMigrationBoltMinSize = 1024
// Docker source paths stored in the committed database.db.old Bolt fixture.
const (
fixturePlaywrightSource = "/app/frontend/tests/playwright-files"
fixtureDockerSource = "/app/backend"
fixtureAccessSource = "/tests/playwright-files"
)
// settingsMigrationBoltPath returns the committed read-only BoltDB reference used by
// migration tests and the Playwright settings Docker image. Tests must never write to it.
func settingsMigrationBoltPath(t *testing.T) string {
t.Helper()
path, err := filepath.Abs(filepath.Join("..", "..", "_docker", "src", "settings", "backend", "database.db.old"))
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf(
"settings migration fixture not found at %s: %v (commit _docker/src/settings/backend/database.db.old)",
path,
err,
)
}
if info.Size() < settingsMigrationBoltMinSize {
t.Fatalf("settings migration fixture at %s is empty or too small (%d bytes)", path, info.Size())
}
return path
}
func settingsMigrationConfigPath(t *testing.T) string {
t.Helper()
path, err := filepath.Abs(filepath.Join("..", "..", "_docker", "src", "settings", "backend", "config.yaml"))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("settings migration config not found at %s: %v", path, err)
}
return path
}
// alignSettingsSourcesForMigrationFixture maps config sources to the Docker paths
// stored in database.db.old so migration tests can run locally without modifying
// the committed Bolt fixture.
func alignSettingsSourcesForMigrationFixture(t *testing.T) {
t.Helper()
byName := map[string]string{
"playwright + files": fixturePlaywrightSource,
"docker": fixtureDockerSource,
"access": fixtureAccessSource,
}
newSourceMap := make(map[string]*settings.Source, len(byName))
for name, dockerPath := range byName {
source, ok := settings.Config.Server.NameToSource[name]
if !ok {
t.Fatalf("source %q not in config", name)
}
if source.Path != dockerPath {
delete(settings.Config.Server.SourceMap, source.Path)
source.Path = dockerPath
}
newSourceMap[dockerPath] = source
settings.Config.Server.NameToSource[name] = source
}
settings.Config.Server.SourceMap = newSourceMap
sources := make([]*settings.Source, 0, len(byName))
for _, name := range []string{"playwright + files", "docker", "access"} {
sources = append(sources, settings.Config.Server.NameToSource[name])
}
settings.Config.Server.Sources = sources
}
+227
View File
@@ -0,0 +1,227 @@
package cmd
import (
"fmt"
"io"
"os"
"strings"
"github.com/alecthomas/kong"
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/term"
)
// Globals holds flags shared across all commands (order-independent).
type Globals struct {
Config string `short:"c" name:"config" default:"config.yaml" env:"FILEBROWSER_CONFIG" help:"Path to config file"`
NoInput bool `name:"no-input" help:"Disable interactive prompts (fail if input required)"`
}
type runCmd struct{}
type versionCmd struct{}
type setupCmd struct{}
type SetCmd struct {
User string `short:"u" help:"Deprecated: comma-separated username,password. Use 'user set' instead."`
Admin bool `short:"a" help:"Create user as admin (used with -u)"`
Rule SetRuleCmd `cmd:"" name:"rule" help:"Add or update an access rule"`
}
type SetRuleCmd struct {
Source string `short:"s" name:"source" aliases:"f,sourceName" required:"" help:"Source name from config"`
Path string `short:"p" name:"path" aliases:"sourcePath" required:"" help:"Index path within the source"`
Role string `short:"r" name:"role" enum:"user,group,all" required:"" help:"Rule target type"`
Value string `short:"v" name:"value" help:"Username or group name (required for user/group)"`
Allow bool `name:"allow" help:"Allow access (default: deny)"`
}
type UserCmd struct {
Set UserSetCmd `cmd:"" name:"set" help:"Create or update a password-authenticated user"`
Promote UserPromoteCmd `cmd:"" name:"promote" help:"Grant admin permissions without changing password"`
}
type UserPromoteCmd struct {
Username string `arg:"" help:"Username to promote"`
}
type UserSetCmd struct {
Username string `arg:"" help:"Username"`
Password passwordFlag `name:"password" help:"Password; omit value to prompt (TTY) or read stdin (pipe)"`
Admin bool `short:"a" name:"admin" help:"Grant admin permissions"`
}
// passwordFlag accepts --password VALUE (inline) or --password alone (prompt/pipe).
type passwordFlag struct {
Provided bool
Inline string
}
func (p *passwordFlag) Decode(ctx *kong.DecodeContext) error {
p.Provided = true
if ctx.Scan.Peek().IsValue() {
return ctx.Scan.PopValueInto("password", &p.Inline)
}
return nil
}
func (p passwordFlag) resolve(noInput bool) (string, error) {
if !p.Provided {
return "", fmt.Errorf("--password is required (inline value, interactive prompt, or piped stdin)")
}
if p.Inline != "" {
return p.Inline, nil
}
if noInput {
return "", fmt.Errorf("--password requires an inline value or piped stdin when --no-input is set")
}
if term.IsTerminal(int(os.Stdin.Fd())) {
return promptPassword()
}
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", fmt.Errorf("read password from stdin: %w", err)
}
password := strings.TrimSpace(string(data))
if password == "" {
return "", fmt.Errorf("password must not be empty")
}
return password, nil
}
func promptPassword() (string, error) {
if _, err := fmt.Fprint(os.Stderr, "Password: "); err != nil {
return "", err
}
bytePassword, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return "", fmt.Errorf("read password: %w", err)
}
password := string(bytePassword)
if password == "" {
return "", fmt.Errorf("password must not be empty")
}
return password, nil
}
var rootCLI cliRoot
type cliRoot struct {
Globals `embed:""`
Run runCmd `cmd:"" default:"1" hidden:"" help:"Start the FileBrowser server"`
Version versionCmd `cmd:"" name:"version" help:"Print version information"`
Setup setupCmd `cmd:"" name:"setup" help:"Interactive configuration setup"`
Set SetCmd `cmd:"" name:"set" help:"Set configuration values (deprecated: use 'user set' for users)"`
User UserCmd `cmd:"" name:"user" help:"User management"`
}
func (versionCmd) Run() error {
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return nil
}
func (setupCmd) Run(globals *Globals) error {
return createConfig(globals.Config, globals.NoInput)
}
func (s *SetCmd) Run(ctx *kong.Context) error {
if strings.HasPrefix(ctx.Command(), "set rule") {
return nil
}
if s.User == "" {
return fmt.Errorf("missing subcommand for 'set'. Use 'set rule' or 'user set'")
}
fmt.Fprintln(os.Stderr, "warning: 'set -u' is deprecated; use 'user set' instead")
userInfo := strings.SplitN(s.User, ",", 2)
if len(userInfo) < 2 || userInfo[0] == "" || userInfo[1] == "" {
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", strings.Split(s.User, ","))
}
return setUser(userInfo[0], userInfo[1], s.Admin)
}
func (r *SetRuleCmd) Run() error {
sourceInfo, ok := settings.Config.Server.NameToSource[r.Source]
if !ok {
return fmt.Errorf("invalid source name: %s", r.Source)
}
return setRule(sourceInfo.Path, r.Path, r.Role, r.Value, r.Allow)
}
func (u *UserSetCmd) Run(globals *Globals) error {
password, err := u.Password.resolve(globals.NoInput)
if err != nil {
return err
}
return setUser(u.Username, password, u.Admin)
}
func (p *UserPromoteCmd) Run() error {
return promoteUser(p.Username)
}
func resolveConfigPath(config *string) {
envConfig := os.Getenv("FILEBROWSER_CONFIG")
if *config == "" {
if envConfig != "" {
*config = envConfig
} else {
*config = "config.yaml"
}
}
}
func requireExistingConfig(config string) {
envConfig := os.Getenv("FILEBROWSER_CONFIG")
if envConfig != "" && config == envConfig {
if _, err := os.Stat(config); err != nil {
logger.Fatalf("config file %v does not exist, please create it or set the FILEBROWSER_CONFIG environment variable to a valid config file path", config)
}
}
}
func runCLI() (keepGoing bool, dbExists bool) {
generateYaml()
parser, err := kong.New(&rootCLI,
kong.Name("filebrowser"),
kong.Description("FileBrowser Quantum - A modern web-based file manager"),
kong.Bind(&rootCLI.Globals),
)
if err != nil {
logger.Fatalf("failed to configure CLI: %v", err)
}
ctx, err := parser.Parse(os.Args[1:])
parser.FatalIfErrorf(err)
resolveConfigPath(&rootCLI.Config)
configPath = rootCLI.Config
cmd := ctx.Command()
switch {
case cmd == "" || cmd == "run":
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath, false)
return true, dbExists
case cmd == "version" || cmd == "setup":
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, false
case cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath, true)
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, dbExists
default:
parser.Fatalf("unexpected command: %q", cmd)
return false, false
}
}
+483
View File
@@ -0,0 +1,483 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
const migrationGuideURL = "https://filebrowserquantum.com/en/docs/getting-started/v2/migration"
func migrationErrf(format string, args ...any) error {
return fmt.Errorf(format+" See the v2 migration guide: "+migrationGuideURL, args...)
}
// checkMigrationNeeded is true when server.database.migrateFrom points at a BoltDB file to import and
// server.database.path (SQLite) does not exist yet or is empty.
func checkMigrationNeeded() bool {
boltPath := settings.Config.Server.DatabaseV2.MigrateFrom
if boltPath == "" {
return false
}
if sqliteDatabasePopulated(settings.Config.Server.DatabaseV2.Path) {
return false
}
return legacyBoltDatabasePopulated(boltPath)
}
// validateDatabasePaths enforces database path rules before opening or creating SQLite:
// 1. On a fresh install, fail if server.database.path is database.db or an unrenamed
// legacy database file (database.db) is present in the working directory.
// 2. On a fresh install with server.database.migrateFrom set, fail if the legacy database file is missing
// or empty.
// 3. When SQLite already exists and server.database.migrateFrom is set, fail if the legacy database file
// is missing or empty.
//
// Otherwise the existing SQLite database is used, or a new one is created on first open.
func validateDatabasePaths() error {
sqlitePath := settings.Config.Server.DatabaseV2.Path
boltPath := settings.Config.Server.DatabaseV2.MigrateFrom
sqliteExists := sqliteDatabasePopulated(sqlitePath)
if !sqliteExists {
if filepath.Base(sqlitePath) == "database.db" {
return migrationErrf(
`server.database.path cannot be "database.db": that name is reserved for the legacy database file. ` +
`Rename database.db to database.db.old, set server.database.path to a new SQLite filename ` +
`and set server.database.migrateFrom to "database.db.old".`,
)
}
if _, err := os.Stat("database.db"); err == nil {
return migrationErrf(
`legacy database file found in the working directory. ` +
`Rename it to database.db.old, set server.database.migrateFrom to "database.db.old" ` +
`or use migrateFrom: "default".`,
)
}
if boltPath != "" {
return legacyBoltDatabaseError(boltPath, sqlitePath, true)
}
return nil
}
if boltPath != "" {
return legacyBoltDatabaseError(boltPath, sqlitePath, false)
}
return nil
}
func legacyBoltDatabaseError(boltPath, sqlitePath string, freshInstall bool) error {
stat, err := os.Stat(boltPath)
if err != nil {
if os.IsNotExist(err) {
if freshInstall {
return migrationErrf(
`server.database.migrateFrom is %q but that file does not exist, and no SQLite database exists at server.database.path (%q). `+
`Check the migrateFrom path or restore your renamed legacy database backup.`,
boltPath, sqlitePath,
)
}
return migrationErrf(
`server.database.migrateFrom is %q but that file does not exist. `+
`Migration appears complete — remove server.database.migrateFrom from your config.`,
boltPath,
)
}
return migrationErrf(
`server.database.migrateFrom is %q but cannot be read: %v. `+
`Check file permissions or restore your backed-up database file.`,
boltPath, err,
)
}
if stat.Size() == 0 {
if freshInstall {
return migrationErrf(
`server.database.migrateFrom is %q but that file is empty, and no SQLite database exists at server.database.path (%q). `+
`Restore your backed-up database file or fix the migrateFrom path.`,
boltPath, sqlitePath,
)
}
return migrationErrf(
`server.database.migrateFrom is %q but that file is empty. `+
`Remove server.database.migrateFrom from your config, or restore the legacy database backup if migration is not complete.`,
boltPath,
)
}
return nil
}
func sqliteDatabasePopulated(path string) bool {
stat, err := os.Stat(path)
return err == nil && stat.Size() > 0
}
func legacyBoltDatabasePopulated(path string) bool {
stat, err := os.Stat(path)
return err == nil && stat.Size() > 0
}
// migrateFromBoltToSQLite migrates essential data from BoltDB to SQLite
func migrateFromBoltToSQLite() error {
oldDBPath := settings.Config.Server.DatabaseV2.MigrateFrom
newDBPath := settings.Config.Server.DatabaseV2.Path
logger.Info("========================================")
logger.Info("Starting migration from legacy database")
logger.Info("========================================")
// Open old BoltDB (read-only)
logger.Info("Opening old database...")
oldDB, err := storm.Open(oldDBPath)
if err != nil {
return fmt.Errorf("failed to open old database: %w", err)
}
logger.Info("✓ Old database opened")
// Initialize new database
logger.Info("Initializing new database...")
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(newDBPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
oldDB.Close()
return fmt.Errorf("failed to initialize new database: %w", err)
}
logger.Info("✓ New database initialized")
// Ensure cleanup on error - remove incomplete database for retry
migrationSuccess := false
defer func() {
oldDB.Close()
sqlStore.Close()
if !migrationSuccess {
// Migration failed - remove the new database so it can be retried
logger.Warning("Migration failed, cleaning up new database file for retry...")
if err := os.Remove(newDBPath); err != nil && !os.IsNotExist(err) {
logger.Errorf("Failed to remove incomplete database file: %v", err)
} else {
logger.Info("✓ Incomplete database file removed, migration can be retried on next startup")
}
}
}()
// Migrate users (bolt User.ID is stored as SQLite users.user_id)
logger.Info("Migrating users...")
if err := migrateUsers(oldDB, sqlStore); err != nil {
return fmt.Errorf("failed to migrate users: %w", err)
}
if err := state.EnsureNoAuthAdminUserAfterMigration(sqlStore); err != nil {
return fmt.Errorf("failed to configure noauth admin user: %w", err)
}
// Migrate shares
logger.Info("Migrating shares...")
if err := migrateShares(oldDB, sqlStore); err != nil {
return fmt.Errorf("failed to migrate shares: %w", err)
}
// Migrate access rules and groups
logger.Info("Migrating access rules and groups...")
if err := migrateAccessRules(oldDB, sqlStore); err != nil {
return fmt.Errorf("failed to migrate access rules: %w", err)
}
// Migrate index info
logger.Info("Migrating index info...")
if err := migrateIndexInfo(oldDB, sqlStore); err != nil {
return fmt.Errorf("failed to migrate index info: %w", err)
}
// Mark migration as successful
migrationSuccess = true
logger.Info("========================================")
logger.Info("Migration completed successfully!")
logger.Info("========================================")
logger.Infof("Your legacy database file is unchanged at: %s", oldDBPath)
logger.Infof("New SQLite database created at: %s", newDBPath)
return nil
}
// normalizeUserScopesBeforeSQLite copies scopes from FrontendScopes into BackendScopes when the Bolt
// record only had JSON key "scopes" (FrontendUser) and nothing under backendScopes — CreateUser
// persists only BackendScopes in SQLite user_data.
func normalizeUserScopesBeforeSQLite(user *users.User) error {
if len(user.BackendScopes) > 0 {
return nil
}
backendScopes, err := users.APIScopesToBackend(user.FrontendScopes)
if err != nil {
return fmt.Errorf("convert frontend scopes to backend: %w", err)
}
for i := range backendScopes {
backendScopes[i].Permissions = users.SourceFilePermissions{
View: true,
Download: user.Permissions.Download,
Modify: user.Permissions.Modify,
Delete: user.Permissions.Delete,
Create: user.Permissions.Create,
}
}
user.FrontendScopes = nil
user.BackendScopes = backendScopes
return nil
}
// migrateUsers migrates all users from BoltDB to SQLite. Each bolt user.ID is written as user_id
func migrateUsers(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
var usersList []*users.User
err := oldDB.All(&usersList)
if err != nil {
if err.Error() == "not found" {
logger.Info(" No users to migrate")
return nil
}
return fmt.Errorf("failed to read users from old DB: %w", err)
}
promoted := 0
for _, user := range usersList {
oldScopesCount := len(user.FrontendScopes)
newScopesCount := len(user.BackendScopes)
if err := normalizeUserScopesBeforeSQLite(user); err != nil {
return fmt.Errorf("failed to normalize scopes for user %s: %w", user.Username, err)
}
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
}
if merged, changed := usersidebar.EnsureSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes); changed {
user.SidebarLinks = merged
}
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
}
prevVersion := user.Version
if len(user.BackendScopes) == 0 {
settings.ApplyUserDefaults(user)
}
if prevVersion < users.SourcePermissionsMigrationVersion {
// ApplyUserDefaults bumps Version; restore pre-defaults version so legacy global file ops migrate to scopes.
user.Version = prevVersion
users.MigrateToSourcePermissions(user)
}
normalizeUserTokensBeforeSQLite(user)
updateTokens(user)
user.Version = users.ProfileStorageVersion
if newScopesCount > oldScopesCount {
promoted++
logger.Infof(" user %q: legacy database had %d scopes, SQLite now has %d",
user.Username, oldScopesCount, newScopesCount)
}
boltID := user.ID
if err := sqlStore.CreateUser(user); err != nil {
return fmt.Errorf("failed to save user %s (bolt id: %d): %w", user.Username, boltID, err)
}
}
if promoted > 0 {
logger.Infof(" Promoted frontend-only Bolt scopes to backend for %d user(s) before SQLite insert", promoted)
}
logger.Infof(" ✓ Migrated %d users", len(usersList))
return nil
}
// normalizeUserTokensBeforeSQLite ensures Bolt name-keyed tokens have Name set so
// TokensForPersist retains them in SQLite user_data (Bolt often stores the name only as the map key).
func normalizeUserTokensBeforeSQLite(user *users.User) {
if len(user.Tokens) == 0 {
return
}
normalized := make(map[string]users.AuthToken)
for key, token := range user.Tokens {
name := token.Name
if name == "" {
if key == token.Token {
continue
}
name = key
}
if _, exists := normalized[name]; exists {
continue
}
token.Name = name
if token.Token == "" {
token.Token = token.Key
}
token.Permissions = users.SanitizeTokenPermissions(token.Permissions)
users.StoreToken(normalized, token)
}
user.Tokens = normalized
}
// migrateShares migrates all shares from BoltDB to SQLite.
//
// Legacy Bolt "Link" / "Share" JSON (flat CommonShare + Link fields):
// - "source" → Share.SourcePath (backend source path; not the share item path)
// - "path" → Share.Path (index-relative path within that source)
// - "userID" → Share.UserID unchanged (same numeric id as migrated users in SQLite)
// - "password_hash" → Share.PasswordHash (username is never stored on the share row)
//
// New users created after migration get random uint64 ids (utils.RandomUint64ID); Bolt ids 1,2,3…
// remain valid. PrepForFrontend resolves Share.UserID to username for API responses only.
func migrateShares(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
boltShareBuckets := []string{"Share", "Link"}
var sharesList []*share.LegacyShare
for _, bucket := range boltShareBuckets {
var batch []*share.LegacyShare
err := oldDB.Select().Bucket(bucket).Find(&batch)
if err != nil && err != storm.ErrNotFound {
return fmt.Errorf("failed to read shares from legacy DB bucket %q: %w", bucket, err)
}
if len(batch) > 0 {
sharesList = append(sharesList, batch...)
}
}
if len(sharesList) == 0 {
logger.Info(" No shares to migrate")
return nil
}
for _, legacy := range sharesList {
link := legacy.ToShare()
if link.UserID == 0 {
return fmt.Errorf("failed to save share %s: owner user id is missing", link.Hash)
}
// Legacy Bolt "source" → SourcePath; normalize when it was stored as a display name.
if link.SourcePath != "" {
if _, ok := settings.Config.Server.SourceMap[link.SourcePath]; !ok {
if src, ok := settings.Config.Server.NameToSource[link.SourcePath]; ok {
link.SourceName = src.Name
link.SourcePath = src.Path
}
} else if link.SourceName == "" {
link.SourceName = settings.Config.Server.SourceMap[link.SourcePath].Name
}
}
// Do not persist API-only fields; PrepForFrontend fills these on read.
link.ShareURL = ""
link.DownloadURL = ""
link.FrontendShareInfo.SourceURL = ""
link.CanEditShare = false
if err := sqlStore.SaveShare(&link); err != nil {
return fmt.Errorf("failed to save share %s: %w", link.Hash, err)
}
}
logger.Infof(" ✓ Migrated %d shares", len(sharesList))
return nil
}
// migrateAccessRules migrates access control data from BoltDB to SQLite.
// Path rules (AllRules) and group definitions (Groups) are unchanged through migration
func migrateAccessRules(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
// Read the access rules JSON blob from BoltDB
var data []byte
err := oldDB.Get("access_rules", "rules", &data)
if err != nil {
if err.Error() == "not found" {
logger.Info(" No access rules to migrate")
return nil
}
return fmt.Errorf("failed to read access rules: %w", err)
}
// Unmarshal the data
type dbStorage struct {
AllRules access.SourceRuleMap `json:"all_rules"`
Groups access.GroupMap `json:"groups"`
RevokedTokens map[string]struct{} `json:"revoked_tokens"`
HashedTokens map[string]uint `json:"hashed_tokens"`
}
var storage dbStorage
if err := json.Unmarshal(data, &storage); err != nil {
return fmt.Errorf("failed to unmarshal access rules: %w", err)
}
// Migrate access rules
ruleCount := 0
for source, rules := range storage.AllRules {
resolvedSource := source
if info, ok := users.ResolveSourceKey(source); ok {
resolvedSource = info.Path
} else if src, ok := settings.Config.Server.NameToSource[source]; ok {
resolvedSource = src.Path
}
for path, rule := range rules {
err := sqlStore.SaveAccessRule(resolvedSource, path, rule)
if err != nil {
return fmt.Errorf("failed to save access rule: %w", err)
}
ruleCount++
}
}
logger.Infof(" ✓ Migrated %d access rules", ruleCount)
// Migrate groups
for name, members := range storage.Groups {
err := sqlStore.SaveGroup(name, members)
if err != nil {
return fmt.Errorf("failed to save group %s: %w", name, err)
}
}
logger.Infof(" ✓ Migrated %d groups", len(storage.Groups))
// Migrate revoked tokens
for tokenHash := range storage.RevokedTokens {
err := sqlStore.SaveRevokedToken(tokenHash)
if err != nil {
return fmt.Errorf("failed to save revoked token: %w", err)
}
}
logger.Infof(" ✓ Migrated %d revoked tokens", len(storage.RevokedTokens))
// Migrate hashed tokens (bolt stored owner user id)
for tokenHash, userID := range storage.HashedTokens {
if userID == 0 {
logger.Warningf(" skipping hashed token: invalid user id 0")
continue
}
err := sqlStore.SaveHashedToken(tokenHash, uint64(userID))
if err != nil {
return fmt.Errorf("failed to save hashed token: %w", err)
}
}
logger.Infof(" ✓ Migrated %d hashed tokens", len(storage.HashedTokens))
return nil
}
// migrateIndexInfo migrates index info metadata from BoltDB to SQLite
func migrateIndexInfo(oldDB *storm.DB, sqlStore *sqldb.SQLStore) error {
var indexList []*dbindex.IndexInfo
err := oldDB.All(&indexList)
if err != nil {
if err.Error() == "not found" {
logger.Info(" No index info to migrate")
return nil
}
return fmt.Errorf("failed to read index info: %w", err)
}
for _, info := range indexList {
err := sqlStore.SaveIndexInfo(info)
if err != nil {
return fmt.Errorf("failed to save index info for %s: %w", info.Path, err)
}
}
logger.Infof(" ✓ Migrated %d index info entries", len(indexList))
return nil
}
+165
View File
@@ -0,0 +1,165 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestValidateDatabasePaths_missingBoltOnFreshInstall(t *testing.T) {
dir := t.TempDir()
settings.Config.Server.DatabaseV2.MigrateFrom = filepath.Join(dir, "database.db.old")
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
err := validateDatabasePaths()
if err == nil {
t.Fatal("expected error when migrateFrom file is missing on fresh install")
}
}
func TestValidateDatabasePaths_emptyBoltOnFreshInstall(t *testing.T) {
dir := t.TempDir()
boltPath := filepath.Join(dir, "database.db.old")
if err := os.WriteFile(boltPath, nil, 0o600); err != nil {
t.Fatal(err)
}
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
err := validateDatabasePaths()
if err == nil {
t.Fatal("expected error when migrateFrom file is empty on fresh install")
}
}
func TestValidateDatabasePaths_missingBoltWhenSQLiteExists(t *testing.T) {
dir := t.TempDir()
settings.Config.Server.DatabaseV2.MigrateFrom = filepath.Join(dir, "missing.db.old")
sqlitePath := filepath.Join(dir, "filebrowser.sqlite")
if err := os.WriteFile(sqlitePath, []byte("sqlite"), 0o600); err != nil {
t.Fatal(err)
}
settings.Config.Server.DatabaseV2.Path = sqlitePath
if err := validateDatabasePaths(); err == nil {
t.Fatal("expected error when migrateFrom is set but legacy file is missing after migration")
}
}
func TestValidateDatabasePaths_okWhenSQLiteExistsWithoutMigrateFrom(t *testing.T) {
dir := t.TempDir()
settings.Config.Server.DatabaseV2.MigrateFrom = ""
sqlitePath := filepath.Join(dir, "filebrowser.sqlite")
if err := os.WriteFile(sqlitePath, []byte("sqlite"), 0o600); err != nil {
t.Fatal(err)
}
settings.Config.Server.DatabaseV2.Path = sqlitePath
if err := validateDatabasePaths(); err != nil {
t.Fatalf("expected no error: %v", err)
}
if checkMigrationNeeded() {
t.Fatal("migration should not run when sqlite already exists")
}
}
func TestValidateDatabasePaths_okWhenSQLiteAndBoltExist(t *testing.T) {
dir := t.TempDir()
boltPath := filepath.Join(dir, "database.db.old")
if err := os.WriteFile(boltPath, []byte("bolt"), 0o600); err != nil {
t.Fatal(err)
}
sqlitePath := filepath.Join(dir, "filebrowser.sqlite")
if err := os.WriteFile(sqlitePath, []byte("sqlite"), 0o600); err != nil {
t.Fatal(err)
}
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
settings.Config.Server.DatabaseV2.Path = sqlitePath
if err := validateDatabasePaths(); err != nil {
t.Fatalf("expected no error when both databases exist: %v", err)
}
if checkMigrationNeeded() {
t.Fatal("migration should not run when sqlite already exists")
}
}
func TestValidateDatabasePaths_rejectsDatabaseDBAsSQLitePath(t *testing.T) {
dir := t.TempDir()
settings.Config.Server.DatabaseV2.MigrateFrom = ""
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "database.db")
if err := validateDatabasePaths(); err == nil {
t.Fatal("expected error when sqlite path is database.db on fresh install")
}
}
func TestValidateDatabasePaths_rejectsUnrenamedLegacyDatabaseDB(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
settings.Config.Server.DatabaseV2.MigrateFrom = ""
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
if err := os.WriteFile("database.db", []byte("legacy-bolt"), 0o600); err != nil {
t.Fatal(err)
}
if err := validateDatabasePaths(); err == nil {
t.Fatal("expected error when unrenamed database.db exists on fresh install")
}
}
func TestValidateDatabasePaths_migrateFromDefaultResolvesFromEnv(t *testing.T) {
dir := t.TempDir()
boltPath := filepath.Join(dir, "database.db.old")
if err := os.WriteFile(boltPath, []byte("bolt"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("FILEBROWSER_DATABASE", boltPath)
settings.Config.Server.DatabaseV2.MigrateFrom = settings.MigrateFromDefault
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
if err := settings.ResolveDatabasePaths(); err != nil {
t.Fatalf("ResolveDatabasePaths: %v", err)
}
if settings.Config.Server.DatabaseV2.MigrateFrom != boltPath {
t.Fatalf("migrateFrom = %q, want %q", settings.Config.Server.DatabaseV2.MigrateFrom, boltPath)
}
if err := validateDatabasePaths(); err != nil {
t.Fatalf("validateDatabasePaths: %v", err)
}
if !checkMigrationNeeded() {
t.Fatal("expected migration to be needed")
}
}
func TestCheckMigrationNeeded_trueWhenBoltPresentAndSQLiteMissing(t *testing.T) {
dir := t.TempDir()
boltPath := filepath.Join(dir, "database.db.old")
if err := os.WriteFile(boltPath, []byte("bolt"), 0o600); err != nil {
t.Fatal(err)
}
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
if err := validateDatabasePaths(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !checkMigrationNeeded() {
t.Fatal("expected migration to be needed")
}
}
func TestValidateDatabasePaths_okFreshInstallWithoutMigrateFrom(t *testing.T) {
dir := t.TempDir()
settings.Config.Server.DatabaseV2.MigrateFrom = ""
settings.Config.Server.DatabaseV2.Path = filepath.Join(dir, "filebrowser.sqlite")
if err := validateDatabasePaths(); err != nil {
t.Fatalf("expected no error on fresh install without migrateFrom: %v", err)
}
}
+74
View File
@@ -0,0 +1,74 @@
package cmd
import (
"encoding/json"
"os"
"path/filepath"
"testing"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestMigrateAdminTokensRoundTripSQLite(t *testing.T) {
boltPath := settingsMigrationBoltPath(t)
sqlitePath := filepath.Join(t.TempDir(), "migrate-tokens.sqlite")
oldDB, err := storm.Open(boltPath)
if err != nil {
t.Fatal(err)
}
defer oldDB.Close()
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(sqlitePath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
t.Fatal(err)
}
defer sqlStore.Close()
var list []*users.User
if err = oldDB.All(&list); err != nil {
t.Fatal(err)
}
var admin *users.User
for _, u := range list {
if u.Username == "admin" {
admin = u
break
}
}
if admin == nil {
t.Fatal("admin not found")
}
normalizeUserTokensBeforeSQLite(admin)
if err = sqlStore.CreateUser(admin); err != nil {
t.Fatal(err)
}
loaded, err := sqlStore.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
customized, ok := loaded.Tokens["customized"]
if !ok {
t.Fatalf("customized missing after reload: %#v", loaded.Tokens)
}
if !customized.Permissions.Admin {
t.Fatalf("customized admin permission missing after reload: %#v", customized.Permissions)
}
if customized.Permissions.Api || customized.Permissions.Share || customized.Permissions.Realtime {
t.Fatalf("customized should only retain admin global after reload: %#v", customized.Permissions)
}
if customized.Permissions.Modify || customized.Permissions.Create || customized.Permissions.Delete || customized.Permissions.Download {
t.Fatalf("customized legacy file ops should be stripped: %#v", customized.Permissions)
}
out, _ := json.MarshalIndent(map[string]any{
"customizedPermissions": customized.Permissions,
"tokenNames": len(loaded.Tokens),
}, "", " ")
os.Stdout.Write(out)
os.Stdout.Write([]byte("\n"))
}
@@ -0,0 +1,89 @@
package cmd
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// Simulates migrateUsers() steps for a Bolt user without backend scopes or source permissions.
func TestMigrateUsersFlow_appliesConfigDefaultsAndSourcePermissions(t *testing.T) {
loadMigrateTestConfig(t, "../../_docker/src/jwt/backend/config.yaml")
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "legacy-bolt-user",
Permissions: users.Permissions{
Download: true,
Modify: true,
Create: true,
Share: true,
},
},
Version: 2,
}
prevVersion := user.Version
if len(user.BackendScopes) == 0 {
settings.ApplyUserDefaults(user)
}
if prevVersion < users.SourcePermissionsMigrationVersion {
user.Version = prevVersion
if !users.MigrateToSourcePermissions(user) {
t.Fatal("expected source permission migration")
}
}
user.Version = users.ProfileStorageVersion
if !user.Permissions.Share {
t.Fatal("global share should remain after migration")
}
if user.Permissions.Modify || user.Permissions.Download {
t.Fatalf("legacy global file ops should be cleared: %+v", user.Permissions)
}
if len(user.BackendScopes) == 0 {
t.Fatal("ApplyUserDefaults should assign default-enabled sources")
}
perms := user.BackendScopes[0].Permissions
if !perms.View || !perms.Modify || !perms.Create || !perms.Download {
t.Fatalf("scope permissions after migration: %+v", perms)
}
if user.BackendScopes[0].Scope != "/legacy-bolt-user" {
t.Fatalf("createUserDir scope=%q", user.BackendScopes[0].Scope)
}
}
func TestMigrateUsersFlow_existingScopeGetsLegacyGlobalFileOps(t *testing.T) {
loadMigrateTestConfig(t, "../../_docker/src/proxy/backend/config.yaml")
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "scoped",
Permissions: users.Permissions{
Download: true,
Modify: false,
Create: false,
Share: true,
},
},
BackendScopes: []users.BackendScope{
{Path: settings.Config.Server.Sources[0].Path, Scope: "/"},
},
Version: 2,
}
users.MigrateToSourcePermissions(user)
perms := user.BackendScopes[0].Permissions
if !perms.Download || perms.Modify || perms.Create {
t.Fatalf("expected legacy global copied to scope: %+v", perms)
}
}
func loadMigrateTestConfig(t *testing.T, path string) {
t.Helper()
settings.Config = settings.SetDefaults(true)
if err := settings.LoadConfigWithDefaultsForTest(path); err != nil {
t.Fatal(err)
}
}
+142
View File
@@ -0,0 +1,142 @@
package cmd
import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestMigrationFixturePostState(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
alignSettingsSourcesForMigrationFixture(t)
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
t.Fatal(err)
}
defer sqlStore.Close()
playwrightPath := fixturePlaywrightSource
dockerPath := fixtureDockerSource
accessPath := fixtureAccessSource
admin, err := sqlStore.GetUserByUsername("admin")
if err != nil {
t.Fatal(err)
}
if !admin.Permissions.Admin || !admin.Permissions.Api || !admin.Permissions.Share {
t.Fatalf("admin globals=%+v", admin.Permissions)
}
if admin.Permissions.Realtime {
t.Fatal("admin realtime should be false")
}
fullPerms := users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
}
for _, sourcePath := range []string{playwrightPath, dockerPath, accessPath} {
assertSourceFilePerms(t, admin, sourcePath, fullPerms)
}
customized, ok := admin.Tokens["customized"]
if !ok {
t.Fatal("customized token missing")
}
if !customized.Permissions.Admin {
t.Fatalf("customized token globals=%+v", customized.Permissions)
}
if customized.Permissions.Api || customized.Permissions.Share || customized.Permissions.Realtime {
t.Fatalf("customized token should only have admin global: %+v", customized.Permissions)
}
if customized.Permissions.Modify || customized.Permissions.Create || customized.Permissions.Delete || customized.Permissions.Download {
t.Fatalf("customized token legacy file ops should be stripped: %+v", customized.Permissions)
}
basic, err := sqlStore.GetUserByUsername("basic")
if err != nil {
t.Fatal(err)
}
if !basic.Permissions.Share || basic.Permissions.Admin || basic.Permissions.Api {
t.Fatalf("basic globals=%+v", basic.Permissions)
}
viewOnly := users.SourceFilePermissions{View: true}
for _, sourcePath := range []string{playwrightPath, dockerPath, accessPath} {
assertSourceFilePerms(t, basic, sourcePath, viewOnly)
}
graham, err := sqlStore.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
grahamPerms := users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: false,
}
assertSourceFilePerms(t, graham, playwrightPath, grahamPerms)
assertSourceFilePerms(t, graham, dockerPath, grahamPerms)
allRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
playwrightRules := allRules[playwrightPath]
if rule, ok := playwrightRules["/text-files/bash.sh/"]; !ok || len(rule.Deny.Users) != 1 {
t.Fatalf("playwright access rule=%+v ok=%v", rule, ok)
} else if _, denied := rule.Deny.Users["admin"]; !denied {
t.Fatalf("playwright access rule deny=%+v", rule.Deny.Users)
}
accessRules := allRules[accessPath]
if len(accessRules) != 1 {
t.Fatalf("access source rule count=%d", len(accessRules))
}
if rule, ok := accessRules["/"]; !ok || len(rule.Deny.Users) != 1 {
t.Fatalf("access source rule=%+v ok=%v", rule, ok)
} else if _, denied := rule.Deny.Users["basic"]; !denied {
t.Fatalf("access source rule deny=%+v", rule.Deny.Users)
}
shares, err := sqlStore.GetSharesByUserID(admin.ID)
if err != nil {
t.Fatal(err)
}
if len(shares) != 2 {
t.Fatalf("share count=%d", len(shares))
}
shareByHash := make(map[string]*share.Share, len(shares))
for _, s := range shares {
shareByHash[s.Hash] = s
}
if link, ok := shareByHash["lMhwHkF-hqCN92-QIJJZow"]; !ok || link.Path != "/myfolder/" ||
!link.AllowModify || !link.AllowCreate || !link.AllowDelete {
t.Fatalf("myfolder share=%+v", link)
}
if link, ok := shareByHash["dGhQi4AcMhva2Ne-7x7fvw"]; !ok || link.Path != "/test & test.txt/" ||
link.AllowModify || link.AllowCreate || link.AllowDelete {
t.Fatalf("test share=%+v", link)
}
}
func assertSourceFilePerms(t *testing.T, user *users.User, sourcePath string, want users.SourceFilePermissions) {
t.Helper()
got, ok := user.BackendSourcePermissions[sourcePath]
if !ok {
t.Fatalf("user %q missing source permissions for %q", user.Username, sourcePath)
}
if got != want {
t.Fatalf("user %q source %q perms=%+v want=%+v", user.Username, sourcePath, got, want)
}
}
+124
View File
@@ -0,0 +1,124 @@
package cmd
import (
"path/filepath"
"testing"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func writeSidebarMigrationBolt(t *testing.T, path string) {
t.Helper()
db, err := storm.Open(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
user := &users.User{
ID: 1,
FrontendUser: users.FrontendUser{
NonAdminEditable: users.NonAdminEditable{
Password: "$2a$10$IYCsziHjzH0mPc.bZwRuXefQKPVXfFqjdyfVmNcL.XZJsgyfxljDy",
SidebarLinks: []users.SidebarLink{
{
Name: "My Files",
Category: string(users.SidebarLinkSourceMinimal),
Icon: "folder",
SourceName: fixturePlaywrightSource,
Target: "/",
},
{
Name: "External Docs",
Category: string(users.SidebarLinkCustom),
Target: "https://example.com/docs",
Icon: "link",
},
},
},
Username: "sidebaruser",
LoginMethod: users.LoginMethodPassword,
FrontendScopes: []users.FrontendScope{
{Name: fixturePlaywrightSource, Scope: "/"},
{Name: fixtureDockerSource, Scope: "/"},
},
Permissions: users.Permissions{
Download: true,
},
},
Version: 3,
}
if err := db.Save(user); err != nil {
t.Fatal(err)
}
}
func TestMigrationSidebarLinks_customMinimalPartialScopesAndCustomURL(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
alignSettingsSourcesForMigrationFixture(t)
boltPath := filepath.Join(t.TempDir(), "sidebar-migration.db.old")
writeSidebarMigrationBolt(t, boltPath)
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = boltPath
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
t.Fatal(err)
}
defer sqlStore.Close()
sidebarUser, err := sqlStore.GetUserByUsername("sidebaruser")
if err != nil {
t.Fatal(err)
}
links := sidebarUser.SidebarLinks
if len(links) < 3 {
t.Fatalf("sidebaruser links=%#v, want at least 3 after scope merge", links)
}
var minimalLink *users.SidebarLink
var customLink *users.SidebarLink
var dockerLink *users.SidebarLink
for i := range links {
link := links[i]
switch {
case link.Category == string(users.SidebarLinkSourceMinimal):
minimalLink = &links[i]
case link.Category == string(users.SidebarLinkCustom):
customLink = &links[i]
case link.SourceName == fixtureDockerSource || link.Name == "docker":
dockerLink = &links[i]
}
}
if minimalLink == nil {
t.Fatalf("missing source-minimal link in %#v", links)
}
if minimalLink.Name != "My Files" || minimalLink.Icon != "folder" {
t.Fatalf("custom minimal link not preserved: %#v", *minimalLink)
}
if customLink == nil || customLink.Name != "External Docs" || customLink.Target != "https://example.com/docs" {
t.Fatalf("custom link not preserved: %#v", customLink)
}
if dockerLink == nil {
t.Fatalf("docker scope link missing after merge: %#v", links)
}
frontendLinks := usersidebar.FrontendLinks(links, true)
if len(frontendLinks) < 3 {
t.Fatalf("frontend links=%#v", frontendLinks)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package cmd
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
+56 -57
View File
@@ -11,47 +11,52 @@ import (
_ "net/http/pprof"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/icons"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/analytics"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/icons"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
"github.com/gtsteffaniak/filebrowser/backend/internal/web"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/swaggo/swag"
)
var store *bolt.BoltStore
var runtimeApp *app.App
func getStore(configFile string) bool {
// Use the config file (global flag)
settings.Initialize(configFile)
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
logger.Fatalf("could not load db info: %v", err)
func initializeDatabase(configFile string, cliMode bool) bool {
if cliMode {
settings.InitializeCLI(configFile)
} else {
settings.Initialize(configFile)
}
store = s
return hasDB
}
func generalUsage() {
fmt.Printf(`usage: ./filebrowser <command> [options]
commands:
-h Print help
-c Print the default config file
version Print version information
set -u Username and password for the new user
set -a Create user as admin
set -s Specify a user scope
set -h Print this help message
`)
if err := validateDatabasePaths(); err != nil {
logger.Fatalf("%v", err)
}
if checkMigrationNeeded() {
logger.Info("Old database detected, starting migration...")
err := migrateFromBoltToSQLite()
if err != nil {
logger.Fatalf("Migration failed: %v", err)
}
}
store, existingDb, err := state.Open(settings.Config.Server.DatabaseV2.Path)
if err != nil {
logger.Fatalf("could not initialize state: %v", err)
}
runtimeApp, err = app.WireServices(store)
if err != nil {
logger.Fatalf("could not wire services: %v", err)
}
return existingDb
}
func StartFilebrowser() {
@@ -59,9 +64,9 @@ func StartFilebrowser() {
if !keepGoing {
return
}
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.DatabaseV2.Path)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.DatabaseV2.Path)
}
if !settings.Config.Server.DisableUpdateCheck {
info, _ := utils.CheckForUpdates()
@@ -83,8 +88,8 @@ func StartFilebrowser() {
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
// Dev mode enables development features like template hot-reloading
_, err := os.Stat("http/dist")
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
_, err := os.Stat("internal/web/dist")
// In dev mode, always use filesystem assets. Otherwise, check if internal/web/dist exists
if !settings.Env.IsDevMode {
settings.Env.EmbeddedFs = os.IsNotExist(err)
}
@@ -115,7 +120,7 @@ func StartFilebrowser() {
}
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL
swagInfo.BasePath = settings.Config.Http.BaseURL
swag.Register(docs.SwaggerInfo.InstanceName(), swagInfo)
// initialize indexing and schedule indexing ever n minutes (default 5)
if len(settings.Config.Server.SourceMap) == 0 {
@@ -128,26 +133,24 @@ func StartFilebrowser() {
logger.Fatalf("Failed to initialize index database: %v", err)
}
// Set indexing storage for persistence
if store != nil && store.Indexing != nil {
indexing.SetIndexingStorage(store.Indexing)
if isNewDb {
if err := store.Indexing.ResetAllComplexities(); err != nil {
logger.Errorf("Failed to reset index complexities: %v", err)
}
// Index metadata persistence is wired from app.WireServices during initializeDatabase.
if isNewDb {
if err := state.ResetAllIndexComplexities(); err != nil {
logger.Errorf("Failed to reset index complexities: %v", err)
}
}
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false, isNewDb)
}
analytics.StartReporter()
validateUserInfo(!dbExists)
validateOfficeIntegration()
validateAccessRules()
validateShareInfo()
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
if err := rootCMD(ctx, &serverConfig, runtimeApp, shutdownComplete); err != nil {
logger.Fatalf("Error starting filebrowser: %v", err)
}
close(done) // Signal that the server has stopped
@@ -183,17 +186,16 @@ func StartFilebrowser() {
logger.Info("Shutdown complete.")
}
func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.Server, shutdownComplete chan struct{}) error {
func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shutdownComplete chan struct{}) error {
if serverConfig.NumImageProcessors < 1 {
logger.Fatal("Image resize workers count could not be < 1")
}
cacheDir := settings.Config.Server.CacheDir
numWorkers := settings.Config.Server.NumImageProcessors
ffmpeg.SetFFmpegPaths()
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
embeddedAssets := fbhttp.GetEmbeddedAssets()
embeddedAssets := web.GetEmbeddedAssets()
subAssets, err := fs.Sub(embeddedAssets, "embed")
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
@@ -209,8 +211,6 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
logger.Fatalf("Error starting preview service: %v", err)
}
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
logger.Debugf("Exiftool Enabled : %v", settings.Config.Integrations.Media.ExiftoolPath != "")
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
@@ -220,11 +220,10 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
// Initialize PWA manifest after icons are generated
icons.InitializePWAManifest()
// Initialize WebAuthn/Passkey service if enabled
if err := auth.InitWebAuthn(); err != nil {
logger.Fatalf("Failed to initialize WebAuthn: %v", err)
}
fbhttp.StartHttp(ctx, store, shutdownComplete)
web.StartHttp(ctx, web.Deps{
Store: a.Store,
Files: a.Files,
Auth: a.Auth,
}, shutdownComplete)
return nil
}
+25 -27
View File
@@ -1,18 +1,15 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/go-logger/logger"
)
// validateShareInfo migrates share links to add default sidebar links
func validateShareInfo() {
if store.Share == nil {
return
}
// Get all shares
shares, err := store.Share.All()
shares, err := state.GetAllShares()
if err != nil {
logger.Debugf("No shares found or error getting shares: %v", err)
return
@@ -22,27 +19,28 @@ func validateShareInfo() {
for _, link := range shares {
// Check if this share needs migration (version not set or 0)
if link.Version == 0 {
// Add default sidebar links
link.SidebarLinks = []users.SidebarLink{
{
Name: "Share QR Code and Info",
Category: "shareInfo",
Target: "#",
Icon: "qr_code",
},
{
Name: "Download",
Category: "download",
Target: "#",
Icon: "download",
},
}
// Set version to 1 to indicate migration is complete
link.Version = 1
hash := link.Hash
// Save the updated share
if err := store.Share.Save(link); err != nil {
if err := state.UpdateShare(hash, func(existingShare *share.Share) error {
// Set version to 1 to indicate migration is complete
existingShare.Version = 1
// Add default sidebar links
existingShare.SidebarLinks = []users.SidebarLink{
{
Name: "Share QR Code and Info",
Category: "shareInfo",
Target: "#",
Icon: "qr_code",
},
{
Name: "Download",
Category: "download",
Target: "#",
Icon: "download",
},
}
return nil
}); err != nil {
logger.Errorf("Failed to migrate share %s: %v", link.Hash, err)
continue
}
+136 -136
View File
@@ -6,9 +6,11 @@ import (
"reflect"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -16,121 +18,138 @@ var createBackup = false
func validateUserInfo(newDB bool) {
// update source info for users if names/sources/paths might have changed
usersList, err := store.Users.Gets()
usersList, err := state.GetAllUsers()
if err != nil {
logger.Fatalf("could not load users: %v", err)
}
for _, user := range usersList {
for i := range usersList {
user := &usersList[i]
changedFields := make([]string, 0, 8)
changePass := false
updateUser := false
if updateUserScopes(user) {
updateUser = true
scopesChanged := updateUserScopes(user)
if scopesChanged {
changedFields = append(changedFields, "backendScopes")
}
if updatePermissions(user) {
updateUser = true
changedFields = append(changedFields, "permissions", "perm", "version")
}
if updateSourcePermissions(user) {
changedFields = append(changedFields, "backendScopes", "backendSourcePermissions", "version")
}
if updatePreviewSettings(user) {
updateUser = true
changedFields = append(changedFields, "preview")
}
if updateLoginType(user) {
updateUser = true
changedFields = append(changedFields, "loginMethod")
}
if updateShowFirstLogin(user) {
updateUser = true
changedFields = append(changedFields, "showFirstLogin")
}
if updateSidebarLinks(user) {
updateUser = true
if updateSidebarLinks(user, scopesChanged) {
changedFields = append(changedFields, "sidebarLinks")
}
if updateTokens(user) {
updateUser = true
changedFields = append(changedFields, "tokens", "version")
}
if updateShowToolsInSidebar(user) {
updateUser = true
if normalizeApiTokenPermissions(user) {
changedFields = append(changedFields, "tokens")
}
if state.ApplyEnforcedSyncToUser(user) {
changedFields = append(changedFields, settings.UserJSONFieldsForEnforcedSync()...)
}
if state.ApplyEnforcedSourcePermissionsSyncToUser(user) {
changedFields = append(changedFields, "backendScopes", "backendSourcePermissions")
}
if user.Version < users.ProfileStorageVersion {
user.Version = users.ProfileStorageVersion
changedFields = append(changedFields, "version")
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
if user.Username == adminUser && adminPass != "" && user.LoginMethod == users.LoginMethodPassword {
if adminUser == "" {
adminUser = "admin"
}
if user.Username == adminUser && user.Permissions.Admin {
adminPerms := settings.AdminPerms()
if user.Permissions.Share != adminPerms.Share || user.Permissions.Api != adminPerms.Api {
user.Permissions.Share = adminPerms.Share
user.Permissions.Api = adminPerms.Api
user.Permissions.Admin = true
changedFields = append(changedFields, "permissions")
}
}
if user.Username == adminUser && settings.Config.Auth.AdminPassword != "" && user.LoginMethod == users.LoginMethodPassword {
logger.Info("Resetting admin user to default username and password.")
user.Permissions.Admin = true
user.Permissions = settings.AdminPerms()
user.Password = settings.Config.Auth.AdminPassword
updateUser = true
user.LoginMethod = users.LoginMethodPassword
changedFields = append(changedFields, "permissions", "password", "loginMethod")
changePass = true
}
if updateUser {
skipCreateBackup := os.Getenv("FILEBROWSER_DISABLE_AUTOMATIC_BACKUP") == "true" || newDB
if createBackup && !skipCreateBackup {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
err = fileutils.CopyFile(settings.Config.Server.Database, fmt.Sprintf("%s.bak", settings.Config.Server.Database))
if err != nil {
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
}
}
fields := []string{"Scopes", "SidebarLinks", "Tokens", "Permissions", "Preview", "ShowFirstLogin", "LoginMethod", "Version", "ShowToolsInSidebar"}
if changePass {
fields = append(fields, "Password")
}
err := store.Users.Update(user, true, fields...)
changedFields = dedupeFields(changedFields)
if len(changedFields) == 0 {
continue
}
skipCreateBackup := os.Getenv("FILEBROWSER_DISABLE_AUTOMATIC_BACKUP") == "true" || newDB
if createBackup && !skipCreateBackup {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
err = fileutils.CopyFile(settings.Config.Server.DatabaseV2.Path, fmt.Sprintf("%s.bak", settings.Config.Server.DatabaseV2.Path))
if err != nil {
logger.Errorf("could not update user: %v", err)
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
}
}
plainPass := ""
if changePass {
plainPass = user.Password
}
if err := state.UpdateUser(user, plainPass, changedFields...); err != nil {
logger.Errorf("could not update user: %v", err)
}
}
}
func dedupeFields(fields []string) []string {
seen := make(map[string]struct{}, len(fields))
out := make([]string, 0, len(fields))
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
key := strings.ToLower(field)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, field)
}
return out
}
func updateUserScopes(user *users.User) bool {
newScopes := []users.SourceScope{}
seen := make(map[string]struct{})
// Build map of existing scopes keyed by canonical source path (DB may use path or display name).
existing := make(map[string]users.SourceScope)
for _, s := range user.Scopes {
if info, ok := users.ResolveSourceKey(s.Name); ok {
existing[info.Path] = s
}
}
// Preserve order by using Config.Server.Sources
for _, src := range settings.Config.Server.Sources {
existingScope, ok := existing[src.Path]
if ok {
// If scope is empty and there's a default, apply default
if existingScope.Scope == "" {
existingScope.Scope = src.Config.DefaultUserScope
}
} else if src.Config.DefaultEnabled {
existingScope.Scope = src.Config.DefaultUserScope
} else {
continue
}
newScopes = append(newScopes, users.SourceScope{
Name: src.Path,
Scope: existingScope.Scope,
})
seen[src.Path] = struct{}{}
}
// Preserve explicit or legacy scopes not already merged (e.g. non-defaultEnabled sources, removed sources).
for _, s := range user.Scopes {
if info, ok := users.ResolveSourceKey(s.Name); ok {
if _, already := seen[info.Path]; already {
continue
}
}
newScopes = append(newScopes, s)
}
changed := !reflect.DeepEqual(user.Scopes, newScopes)
user.Scopes = newScopes
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
user.BackendScopes = newScopes
return changed
}
// updateShowToolsInSidebar one-time defaults for legacy users (Version < CurrentUserMigrationVersion) from configured userDefaults.
func updateShowToolsInSidebar(user *users.User) bool {
if user.Version >= 3 {
return false
func updateSourcePermissions(user *users.User) bool {
changed := false
if user.Version < users.SourcePermissionsMigrationVersion {
if users.MigrateToSourcePermissions(user) {
changed = true
}
}
user.ShowToolsInSidebar = true
user.Version = users.CurrentUserMigrationVersion
return true
if users.EnsureSourcePermissionsForScopes(
user,
settings.DefaultSourceFilePermissions(),
settings.AdminSourceFilePermissions(),
) {
changed = true
}
return changed
}
func updateShowFirstLogin(user *users.User) bool {
@@ -147,6 +166,7 @@ func updatePermissions(user *users.User) bool {
return false
}
updateUser := true
user.Permissions.Download = true
// if any keys are true, set the permissions to true
if user.Perm.Api {
user.Permissions.Api = true
@@ -188,7 +208,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = users.CurrentUserMigrationVersion
user.Version = 2
if updateUser {
createBackup = true
}
@@ -213,58 +233,18 @@ func updatePreviewSettings(user *users.User) bool {
return false
}
// updateSidebarLinks checks if user has stale source links and rebuilds them if needed
func updateSidebarLinks(user *users.User) bool {
// Count source links and check if any are still valid
sourceLinksCount := 0
validSourceLinksCount := 0
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
sourceLinksCount++
if link.SourceName == "" {
continue
}
if _, ok := users.ResolveSourceKey(link.SourceName); ok {
validSourceLinksCount++
}
}
// updateSidebarLinks normalizes saved links and adds defaults for new or changed scopes.
func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
links, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks)
if scopesChanged || user.SidebarLinks == nil {
links, changed = usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
}
// If user has no source links, don't update anything
if sourceLinksCount == 0 {
if !changed {
return false
}
// If user has source links but NONE are valid, rebuild from their scopes
if validSourceLinksCount == 0 {
// Remove all existing source links
newLinks := []users.SidebarLink{}
for _, link := range user.SidebarLinks {
if !strings.HasPrefix(link.Category, "source") {
newLinks = append(newLinks, link)
}
}
for _, scope := range user.Scopes {
info, ok := users.ResolveSourceKey(scope.Name)
if !ok {
continue
}
newLinks = append(newLinks, users.SidebarLink{
Name: info.Name,
Category: "source",
Target: "/",
Icon: "",
SourceName: info.Path,
})
}
user.SidebarLinks = newLinks
return true
}
return false
user.SidebarLinks = links
return true
}
func updateTokens(user *users.User) bool {
@@ -275,9 +255,29 @@ func updateTokens(user *users.User) bool {
user.Tokens = make(map[string]users.AuthToken)
for name, token := range user.ApiKeys {
token.Token = token.Key
user.Tokens[name] = token
token.Name = name
users.StoreToken(user.Tokens, token)
}
}
user.Version = users.CurrentUserMigrationVersion
user.Version = 2
return true
}
func normalizeApiTokenPermissions(user *users.User) bool {
if user == nil || len(user.Tokens) == 0 {
return false
}
changed := false
for name, token := range user.Tokens {
if token.Name == "" || name != token.Name {
continue
}
sanitized := users.SanitizeTokenPermissions(token.Permissions)
if sanitized != token.Permissions {
token.Permissions = sanitized
user.Tokens[name] = token
changed = true
}
}
return changed
}
+124
View File
@@ -0,0 +1,124 @@
package cmd
import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestUpdateUserScopes_mergesMissingDefaultEnabled(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{Username: "graham"},
BackendScopes: []users.BackendScope{
{Path: fixturePlaywrightSource, Scope: "/myfolder"},
{Path: fixtureDockerSource, Scope: "/"},
},
}
if !updateUserScopes(user) {
t.Fatal("expected missing defaultEnabled access source to be merged")
}
if len(user.BackendScopes) != 3 {
t.Fatalf("scopes=%#v want 3 including access", user.BackendScopes)
}
found := false
for _, scope := range user.BackendScopes {
if scope.Path == fixtureAccessSource {
found = true
break
}
}
if !found {
t.Fatalf("access source should be added for defaultEnabled: %#v", user.BackendScopes)
}
// Preserve existing scope paths
for _, scope := range user.BackendScopes {
if scope.Path == fixturePlaywrightSource && scope.Scope != "/myfolder" {
t.Fatalf("playwright scope path changed: %#v", scope)
}
}
}
func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
alignSettingsSourcesForMigrationFixture(t)
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
settings.Config.Server.DatabaseV2.Path = dbPath
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
if err := migrateFromBoltToSQLite(); err != nil {
t.Fatal(err)
}
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
if err != nil {
t.Fatal(err)
}
defer sqlStore.Close()
graham, err := sqlStore.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if graham.BackendSourcePermissions == nil {
t.Fatal("expected backend source permissions after migration")
}
// Migration fixture itself may omit access; merge happens in updateUserScopes.
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; ok {
t.Fatalf("graham should not have access permissions from migration alone: %#v", graham.BackendSourcePermissions)
}
updateUserScopes(graham)
updateSourcePermissions(graham)
updateSidebarLinks(graham, true)
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
foundScope = true
break
}
}
if !foundScope {
t.Fatalf("graham should gain access scope after merge: %#v", graham.BackendScopes)
}
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; !ok {
t.Fatalf("graham should have access source permissions after merge: %#v", graham.BackendSourcePermissions)
}
foundLink := false
for _, link := range usersidebar.FrontendLinks(graham.SidebarLinks, true) {
if link.Name == "access" || link.SourceName == fixtureAccessSource {
foundLink = true
break
}
}
if !foundLink {
t.Fatalf("graham should have access sidebar link after merge: %#v", graham.SidebarLinks)
}
}
func TestUpdateUserScopes_seedsDefaultEnabledWhenEmpty(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{Username: "newuser"},
}
if !updateUserScopes(user) {
t.Fatal("expected default-enabled scopes to be seeded")
}
if len(user.BackendScopes) != 3 {
t.Fatalf("scopes=%#v want all default-enabled sources", user.BackendScopes)
}
}
+113
View File
@@ -0,0 +1,113 @@
package cmd
import (
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestUpdateSidebarLinks_dedupesDuplicateMigratedLinks(t *testing.T) {
settingsConfig := "../../_docker/src/settings/backend/config.yaml"
settings.Initialize(settingsConfig)
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
NonAdminEditable: users.NonAdminEditable{
SidebarLinks: []users.SidebarLink{
{Name: "playwright + files", Category: "source", SourceName: "/app/frontend/tests/playwright-files", Target: "/"},
{Name: "docker", Category: "source", SourceName: "/app/backend", Target: "/"},
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
},
},
},
BackendScopes: []users.BackendScope{
{Path: "../frontend/tests/playwright-files", Scope: "/"},
{Path: ".", Scope: "/"},
{Path: "/tests/playwright-files", Scope: "/"},
},
}
if !updateSidebarLinks(user, false) {
t.Fatal("expected updateSidebarLinks to return true")
}
count := 0
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
count++
}
}
if count != 3 {
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
}
}
func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
},
BackendScopes: []users.BackendScope{
{Path: fixturePlaywrightSource, Scope: "/"},
{Path: fixtureDockerSource, Scope: "/"},
{Path: fixtureAccessSource, Scope: "/"},
},
}
if !updateSidebarLinks(user, false) {
t.Fatal("expected updateSidebarLinks to return true")
}
count := 0
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
count++
}
}
if count != 3 {
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
}
}
func TestUpdateSidebarLinks_keepsDeletedSourceLinkAbsent(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
NonAdminEditable: users.NonAdminEditable{
SidebarLinks: []users.SidebarLink{
{Name: "playwright + files", Category: "source", SourceName: fixturePlaywrightSource, Target: "/"},
{Name: "docker", Category: "source", SourceName: fixtureDockerSource, Target: "/"},
},
},
},
BackendScopes: []users.BackendScope{
{Path: fixturePlaywrightSource, Scope: "/"},
{Path: fixtureDockerSource, Scope: "/"},
{Path: fixtureAccessSource, Scope: "/"},
},
}
if !updateSidebarLinks(user, false) {
t.Fatal("expected default source names to be blanked on normalize")
}
if len(user.SidebarLinks) != 2 {
t.Fatalf("got %d sidebar links, want 2: %v", len(user.SidebarLinks), user.SidebarLinks)
}
for _, link := range user.SidebarLinks {
if link.Name != "" {
t.Fatalf("expected blank default name, got %#v", link)
}
}
}
+66 -64
View File
@@ -4,8 +4,8 @@ import (
"reflect"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/stretchr/testify/assert"
)
@@ -33,108 +33,110 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
testCases := []struct {
name string
user *users.User
expectedPhase1 []users.SourceScope
expectedPhase2 []users.SourceScope
expectedPhase1 []users.BackendScope
expectedPhase2 []users.BackendScope
}{
{
name: "Single valid scope",
user: &users.User{
Scopes: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
BackendScopes: []users.BackendScope{
{Scope: "/home", Path: "/pathA"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
expectedPhase1: []users.BackendScope{
{Scope: "/home", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
// Phase2: both already present after phase1 merge.
expectedPhase2: []users.BackendScope{
{Scope: "/home", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
{
name: "Single empty scope path",
user: &users.User{
Scopes: []users.SourceScope{
{Scope: "", Name: "/pathB"},
BackendScopes: []users.BackendScope{
{Scope: "", Path: "/pathB"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultB", Name: "/pathB"},
expectedPhase1: []users.BackendScope{
{Scope: "/defaultB", Path: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
{
name: "Two scopes, one includes username in path",
user: &users.User{
Username: "user123",
Scopes: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
FrontendUser: users.FrontendUser{Username: "user123"},
BackendScopes: []users.BackendScope{
{Scope: "/home/user123", Path: "/pathA"},
{Scope: "/data", Path: "/pathB"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
expectedPhase1: []users.BackendScope{
{Scope: "/home/user123", Path: "/pathA"},
{Scope: "/data", Path: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
expectedPhase2: []users.BackendScope{
{Scope: "/home/user123", Path: "/pathA"},
{Scope: "/data", Path: "/pathB"},
},
},
{
name: "Two scopes, one with empty name",
user: &users.User{
Scopes: []users.SourceScope{
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
BackendScopes: []users.BackendScope{
{Scope: "/home", Path: "/pathB"},
{Scope: "/data", Path: "/somethingElse"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
expectedPhase1: []users.BackendScope{
{Scope: "/home", Path: "/pathB"},
{Scope: "/data", Path: "/somethingElse"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/home", Path: "/pathB"},
{Scope: "/data", Path: "/somethingElse"},
},
},
{
name: "No scopes at all",
user: &users.User{
Scopes: []users.SourceScope{},
BackendScopes: []users.BackendScope{},
},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultB", Name: "/pathB"},
expectedPhase1: []users.BackendScope{
{Scope: "/defaultB", Path: "/pathB"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
{
name: "All user Scope and source change",
user: &users.User{
Username: "user123",
Scopes: []users.SourceScope{
{Scope: "/defaultC/user123", Name: "/pathC"},
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
FrontendUser: users.FrontendUser{Username: "user123"},
BackendScopes: []users.BackendScope{
{Scope: "/defaultC/user123", Path: "/pathC"},
{Scope: "/defaultA/user123", Path: "/pathA"},
{Scope: "/defaultB/user123", Path: "/pathB"},
},
},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
{Scope: "/defaultC/user123", Name: "/pathC"},
// updateUserScopes emits configured sources first (Sources order), then unknown paths.
expectedPhase1: []users.BackendScope{
{Scope: "/defaultA/user123", Path: "/pathA"},
{Scope: "/defaultB/user123", Path: "/pathB"},
{Scope: "/defaultC/user123", Path: "/pathC"},
},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
{Scope: "/defaultC/user123", Name: "/pathC"},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA/user123", Path: "/pathA"},
{Scope: "/defaultB/user123", Path: "/pathB"},
{Scope: "/defaultC/user123", Path: "/pathC"},
},
},
}
@@ -144,10 +146,10 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
// ---------------------
for _, tc := range testCases {
t.Run(tc.name+"_Phase1", func(t *testing.T) {
originalScopes := tc.user.Scopes
originalScopes := tc.user.BackendScopes
updated := updateUserScopes(tc.user)
assert.Equal(t, tc.expectedPhase1, tc.user.Scopes, "Phase1 scope mismatch for test case: %s", tc.name)
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.Scopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.Scopes, tc.expectedPhase1)
assert.Equal(t, tc.expectedPhase1, tc.user.BackendScopes, "Phase1 scope mismatch for test case: %s", tc.name)
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.BackendScopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.BackendScopes, tc.expectedPhase1)
})
}
@@ -172,10 +174,10 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
// Run again without resetting user objects to test idempotency + renaming
for _, tc := range testCases {
t.Run(tc.name+"_Phase2", func(t *testing.T) {
originalScopes := tc.user.Scopes
originalScopes := tc.user.BackendScopes
updated := updateUserScopes(tc.user)
assert.Equal(t, tc.expectedPhase2, tc.user.Scopes, "Phase2 scope mismatch for test case: %s", tc.name)
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.Scopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.Scopes, tc.expectedPhase1)
assert.Equal(t, tc.expectedPhase2, tc.user.BackendScopes, "Phase2 scope mismatch for test case: %s", tc.name)
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.BackendScopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.BackendScopes, tc.expectedPhase1)
})
}
@@ -1,236 +0,0 @@
package settings
import (
"regexp"
"strings"
"testing"
)
func TestGenerateConfigYaml_Basic(t *testing.T) {
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
// Test using the actual source directory structure
trueVal := true
config := &Settings{
UserDefaults: UserDefaults{
Locale: "es", // Non-default value
DarkMode: &trueVal, // Default value
DisableOfficePreviewExt: ".docx .xlsx", // This field is deprecated
},
Auth: Auth{
Key: "secret123", // This is a secret field
AdminUsername: "testadmin", // This is a secret field
AdminPassword: "testpassword", // This is a secret field
},
}
// Test different combinations
testCases := []struct {
name string
showComments bool
showFull bool
filterDeprecated bool
expectSecrets bool
expectDeprecated bool
}{
{
name: "API_mode_all_fields",
showComments: false,
showFull: true,
filterDeprecated: false,
expectSecrets: true, // Secrets should be hidden
expectDeprecated: true, // Deprecated should be shown
},
{
name: "Static_config_filter_deprecated",
showComments: true,
showFull: true,
filterDeprecated: true,
expectSecrets: true, // Secrets should be hidden
expectDeprecated: false, // Deprecated should be filtered
},
{
name: "Filtered_non_defaults",
showComments: false,
showFull: false,
filterDeprecated: false,
expectSecrets: true, // Secrets should be hidden
expectDeprecated: false, // Might not appear if matches default
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Use the current directory structure for real testing
yamlOutput, err := GenerateConfigYaml(config, tc.showComments, tc.showFull, tc.filterDeprecated)
if err != nil {
t.Fatalf("GenerateConfigYaml failed: %v", err)
}
// Basic validation - should produce some output
if len(yamlOutput) == 0 {
t.Fatal("Generated empty YAML")
}
// Test string quoting - string values should be quoted, numbers and booleans should not
lines := strings.Split(yamlOutput, "\n")
for _, line := range lines {
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
// This is a key-value line, skip comment portion
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
value := strings.TrimSpace(parts[1])
// Remove comments
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
value = strings.TrimSpace(value[:commentIdx])
}
// Skip empty values, arrays, and objects
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") {
continue
}
// Check if it's a number (should NOT be quoted)
if reNumber.MatchString(value) {
if strings.HasPrefix(value, "\"") {
t.Errorf("Numeric value should not be quoted in line: %s (value: '%s')", line, value)
}
continue
}
// Check if it's a boolean (should NOT be quoted)
if value == "true" || value == "false" {
if strings.HasPrefix(value, "\"") {
t.Errorf("Boolean value should not be quoted in line: %s (value: '%s')", line, value)
}
continue
}
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
if value == "null" {
continue
}
// Everything else should be a quoted string
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
// Add debug info to see what's wrong
t.Logf("DEBUG: Checking value '%s' from line '%s'", value, strings.TrimSpace(line))
t.Errorf("String value should be quoted in line: %s (value: %s)", strings.TrimSpace(line), value)
}
}
}
}
// Test secret hiding
if tc.expectSecrets {
if !strings.Contains(yamlOutput, "**hidden**") {
t.Error("Expected secrets to be hidden with **hidden**")
}
if strings.Contains(yamlOutput, "secret123") || strings.Contains(yamlOutput, "testadmin") || strings.Contains(yamlOutput, "testpassword") {
t.Error("Secret values should not appear in output")
}
}
// Test deprecated field filtering
hasDeprecated := strings.Contains(yamlOutput, "disableOfficePreviewExt")
if tc.expectDeprecated && !hasDeprecated {
t.Error("Expected deprecated field to be present")
}
if !tc.expectDeprecated && hasDeprecated && tc.filterDeprecated {
t.Error("Expected deprecated field to be filtered out when filterDeprecated=true")
}
// Test comments
hasComments := strings.Contains(yamlOutput, "#")
if tc.showComments && !hasComments {
t.Error("Expected comments to be present when showComments=true")
}
t.Logf("Test case: %s\nGenerated YAML:\n%s\n", tc.name, yamlOutput)
})
}
}
func TestCollectCommentsAndSecrets_Basic(t *testing.T) {
// Test the comment and secret collection directly
comments, secrets, deprecated, err := CollectCommentsAndSecrets(".")
if err != nil {
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
}
// Should find some secrets in the actual source files
foundSecrets := false
for typeName, typeSecrets := range secrets {
for fieldName := range typeSecrets {
t.Logf("Found secret: %s.%s", typeName, fieldName)
foundSecrets = true
}
}
if !foundSecrets {
t.Error("Expected to find some secret fields in the actual source code")
}
// Should find some deprecated fields
foundDeprecated := false
for typeName, typeDeprecated := range deprecated {
for fieldName := range typeDeprecated {
t.Logf("Found deprecated: %s.%s", typeName, fieldName)
foundDeprecated = true
}
}
if !foundDeprecated {
t.Error("Expected to find some deprecated fields in the actual source code")
}
t.Logf("Comments: %d types", len(comments))
t.Logf("Secrets: %d types", len(secrets))
t.Logf("Deprecated: %d types", len(deprecated))
}
func TestGenerateYaml_StaticGeneration(t *testing.T) {
// Test the static generation function that's used by FILEBROWSER_GENERATE_CONFIG=true
// Create a temporary config for testing
config := &Settings{
UserDefaults: UserDefaults{
Locale: "en",
DisableOfficePreviewExt: ".docx .xlsx", // This should be filtered out
},
Auth: Auth{
Key: "test-secret", // This should be redacted
},
}
// Test static generation with comments and deprecated filtering
yamlOutput, err := GenerateConfigYamlWithSource(config, true, true, true, ".")
if err != nil {
t.Fatalf("Static generation failed: %v", err)
}
// Verify comments are included
if !strings.Contains(yamlOutput, "#") {
t.Error("Static generation should include comments")
}
// Verify deprecated field is filtered out
if strings.Contains(yamlOutput, "disableOfficePreviewExt") {
t.Error("Static generation should filter out deprecated field disableOfficePreviewExt")
}
// Verify secrets are redacted
if !strings.Contains(yamlOutput, "**hidden**") {
t.Error("Static generation should redact secrets")
}
if strings.Contains(yamlOutput, "test-secret") {
t.Error("Static generation should not contain actual secret values")
}
// Verify it has proper structure
if !strings.Contains(yamlOutput, "userDefaults:") {
t.Error("Static generation should contain userDefaults section")
}
if !strings.Contains(yamlOutput, "auth:") {
t.Error("Static generation should contain auth section")
}
t.Logf("Static generation successful with comments and proper filtering")
}
-221
View File
@@ -1,221 +0,0 @@
package settings
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// TestMigrateUserDefaults_OldToNew verifies that deprecated fields are correctly migrated to new fields
func TestMigrateUserDefaults_OldToNew(t *testing.T) {
// Setup: Create UserDefaults with old fields set
Config.UserDefaults = UserDefaults{
// Old deprecated fields
DarkMode: boolPtr(true),
DisableQuickToggles: true,
HideSidebarFileActions: true,
StickySidebar: true,
SingleClick: true,
HideFilesInTree: true,
ShowToolsInSidebar: boolPtr(false),
DeleteWithoutConfirming: true,
DateFormat: true,
ShowHidden: true,
QuickDownload: true,
ShowSelectMultiple: true,
HideFileExt: ".tmp",
ShowCopyPath: true,
DeleteAfterArchive: true,
DisablePreviewExt: ".exe",
EditorQuickSave: true,
DisableViewingExt: ".bin",
DisableOnlyOfficeExt: ".md",
PreferEditorForMarkdown: true,
DebugOffice: true,
DisableSearchOptions: true,
ThemeColor: "var(--red)",
CustomTheme: "dark",
Locale: "de",
LockPassword: true,
DisableSettings: true,
LoginMethod: "oidc",
DisableUpdateNotifications: true,
Permissions: UserDefaultsPermissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: boolPtr(false),
},
Preview: UserDefaultsPreview{
Image: boolPtr(true),
Video: boolPtr(true),
Audio: boolPtr(true),
MotionVideoPreview: boolPtr(true),
Office: boolPtr(true),
PopUp: boolPtr(true),
HighQuality: boolPtr(true),
Folder: boolPtr(true),
Models: boolPtr(true),
// Deprecated fields that should be migrated
DisableHideSidebar: true,
AutoplayMedia: true,
DefaultMediaPlayer: true,
},
FileLoading: users.FileLoading{
MaxConcurrent: 5,
UploadChunkSize: 20,
},
}
// Execute: Run migration
migrateUserDefaults()
// Verify: Check that new fields are populated from old fields
ud := &Config.UserDefaults
// Sidebar fields
if !ud.Sidebar.DisableQuickToggles {
t.Error("sidebar.disableQuickToggles should be true")
}
if !ud.Sidebar.HideFileActions {
t.Error("sidebar.hideFileActions should be true")
}
if !ud.Sidebar.Sticky {
t.Error("sidebar.sticky should be true")
}
if !ud.Sidebar.HideFiles {
t.Error("sidebar.hideFiles should be true")
}
if ud.Sidebar.ShowTools == nil || *ud.Sidebar.ShowTools != false {
t.Error("sidebar.showTools should be false")
}
if !ud.Sidebar.DisableHideOnPreview {
t.Error("sidebar.disableHideOnPreview should be true")
}
// Listing fields
if !ud.Listing.DeleteWithoutConfirming {
t.Error("listing.deleteWithoutConfirming should be true")
}
if !ud.Listing.DateFormat {
t.Error("listing.dateFormat should be true")
}
if !ud.Listing.ShowHidden {
t.Error("listing.showHidden should be true")
}
if !ud.Listing.QuickDownload {
t.Error("listing.quickDownload should be true")
}
if !ud.Listing.ShowSelectMultiple {
t.Error("listing.showSelectMultiple should be true")
}
if !ud.Listing.SingleClick {
t.Error("listing.singleClick should be true")
}
if ud.Listing.HideFileExt != ".tmp" {
t.Errorf("listing.hideFileExt should be '.tmp', got '%s'", ud.Listing.HideFileExt)
}
if !ud.Listing.ShowCopyPath {
t.Error("listing.showCopyPath should be true")
}
if !ud.Listing.DeleteAfterArchive {
t.Error("listing.deleteAfterArchive should be true")
}
// Preview fields
if ud.Preview.Image == nil || *ud.Preview.Image != true {
t.Error("preview.image should be true")
}
if ud.Preview.Video == nil || *ud.Preview.Video != true {
t.Error("preview.video should be true")
}
if ud.Preview.Audio == nil || *ud.Preview.Audio != true {
t.Error("preview.audio should be true")
}
if ud.Preview.DisablePreviewExt != ".exe" {
t.Errorf("preview.disablePreviewExt should be '.exe', got '%s'", ud.Preview.DisablePreviewExt)
}
// FileViewer fields
if !ud.FileViewer.EditorQuickSave {
t.Error("fileViewer.editorQuickSave should be true")
}
if ud.FileViewer.AutoplayMedia == nil || *ud.FileViewer.AutoplayMedia != true {
t.Error("fileViewer.autoplayMedia should be true")
}
if ud.FileViewer.DisableViewingExt != ".bin" {
t.Errorf("fileViewer.disableViewingExt should be '.bin', got '%s'", ud.FileViewer.DisableViewingExt)
}
if ud.FileViewer.DisableOnlyOfficeExt != ".md" {
t.Errorf("fileViewer.disableOnlyOfficeExt should be '.md', got '%s'", ud.FileViewer.DisableOnlyOfficeExt)
}
if !ud.FileViewer.PreferEditorForMarkdown {
t.Error("fileViewer.preferEditorForMarkdown should be true")
}
if !ud.FileViewer.DebugOffice {
t.Error("fileViewer.debugOffice should be true")
}
// Search fields
if !ud.Search.DisableOptions {
t.Error("search.disableOptions should be true")
}
// UI fields
if ud.UI.DarkMode == nil || *ud.UI.DarkMode != true {
t.Error("ui.darkMode should be true")
}
if ud.UI.ThemeColor != "var(--red)" {
t.Errorf("ui.themeColor should be 'var(--red)', got '%s'", ud.UI.ThemeColor)
}
if ud.UI.CustomTheme != "dark" {
t.Errorf("ui.customTheme should be 'dark', got '%s'", ud.UI.CustomTheme)
}
if ud.UI.Locale != "de" {
t.Errorf("ui.locale should be 'de', got '%s'", ud.UI.Locale)
}
// Account fields
if !ud.Account.LockPassword {
t.Error("account.lockPassword should be true")
}
if !ud.Account.DisableSettings {
t.Error("account.disableSettings should be true")
}
if ud.Account.LoginMethod != "oidc" {
t.Errorf("account.loginMethod should be 'oidc', got '%s'", ud.Account.LoginMethod)
}
if !ud.Account.DisableUpdateNotifications {
t.Error("account.disableUpdateNotifications should be true")
}
// Account Permissions
if !ud.Account.Permissions.Api {
t.Error("account.permissions.api should be true")
}
if !ud.Account.Permissions.Admin {
t.Error("account.permissions.admin should be true")
}
if !ud.Account.Permissions.Modify {
t.Error("account.permissions.modify should be true")
}
if !ud.Account.Permissions.Share {
t.Error("account.permissions.share should be true")
}
if !ud.Account.Permissions.Realtime {
t.Error("account.permissions.realtime should be true")
}
if !ud.Account.Permissions.Delete {
t.Error("account.permissions.delete should be true")
}
if !ud.Account.Permissions.Create {
t.Error("account.permissions.create should be true")
}
if ud.Account.Permissions.Download == nil || *ud.Account.Permissions.Download != false {
t.Error("account.permissions.download should be false")
}
}
-173
View File
@@ -1,173 +0,0 @@
//go:generate go run ./tools/yaml.go -input=common/settings/settings.go -output=config.generated.yaml
package settings
import (
"crypto/rand"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// boolPtr returns a pointer to a bool value
func boolPtr(b bool) *bool {
return &b
}
// boolValueOrDefault returns the value of a bool pointer, or the default if nil
func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
if ptr != nil {
return *ptr
}
return defaultValue
}
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
func ConvertPermissionsToUsers(p UserDefaultsAccountPermissions) users.Permissions {
return users.Permissions{
Api: p.Api,
Admin: p.Admin,
Modify: p.Modify,
Share: p.Share,
Realtime: p.Realtime,
Delete: p.Delete,
Create: p.Create,
Download: boolValueOrDefault(p.Download, true),
}
}
const DefaultUsersHomeBasePath = "/users"
// AuthMethod describes an authentication method.
type AuthMethod string
// GenerateKey generates a key of 512 bits.
func GenerateKey() ([]byte, error) {
b := make([]byte, 64)
_, err := rand.Read(b)
// Note that err == nil only if we read len(b) bytes.
if err != nil {
return nil, err
}
return b, nil
}
func GetSettingsConfig(nameType string, Value string) string {
return nameType + Value
}
func AdminPerms() users.Permissions {
return users.Permissions{
Modify: true,
Share: true,
Admin: true,
Api: true,
Download: true,
Delete: true,
Create: true,
Realtime: false,
}
}
// Apply applies the default options to a user.
// Keep this in sync with [UserDefaults]: every user-facing field there should be copied
// here (except DefaultScopes, which is wired through source config rather than this helper).
func ApplyUserDefaults(u *users.User) {
d := Config.UserDefaults
// Account settings
u.DisableSettings = d.Account.DisableSettings
u.LockPassword = d.Account.LockPassword
u.DisableUpdateNotifications = d.Account.DisableUpdateNotifications
// Sidebar settings
u.DisableQuickToggles = d.Sidebar.DisableQuickToggles
u.HideSidebarFileActions = d.Sidebar.HideFileActions
u.StickySidebar = d.Sidebar.Sticky
u.HideFilesInTree = d.Sidebar.HideFiles
u.ShowToolsInSidebar = boolValueOrDefault(d.Sidebar.ShowTools, true)
// Listing settings
u.DeleteWithoutConfirming = d.Listing.DeleteWithoutConfirming
u.DateFormat = d.Listing.DateFormat
u.ShowHidden = d.Listing.ShowHidden
u.QuickDownload = d.Listing.QuickDownload
u.ShowSelectMultiple = d.Listing.ShowSelectMultiple
u.SingleClick = d.Listing.SingleClick
u.HideFileExt = d.Listing.HideFileExt
u.ShowCopyPath = d.Listing.ShowCopyPath
u.DeleteAfterArchive = d.Listing.DeleteAfterArchive
u.ViewMode = d.Listing.ViewMode
u.GallerySize = d.Listing.GallerySize
// Preview settings
u.Preview.DisableHideSidebar = d.Sidebar.DisableHideOnPreview
u.Preview.Image = boolValueOrDefault(d.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(d.Preview.Video, true)
u.Preview.Audio = boolValueOrDefault(d.Preview.Audio, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(d.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(d.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(d.Preview.PopUp, true)
u.Preview.Folder = boolValueOrDefault(d.Preview.Folder, true)
u.Preview.Models = boolValueOrDefault(d.Preview.Models, true)
u.DisablePreviewExt = d.Preview.DisablePreviewExt
// FileViewer settings
u.EditorQuickSave = d.FileViewer.EditorQuickSave
u.Preview.AutoplayMedia = boolValueOrDefault(d.FileViewer.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = d.FileViewer.DefaultMediaPlayer
u.DisableViewingExt = d.FileViewer.DisableViewingExt
u.DisableOnlyOfficeExt = d.FileViewer.DisableOnlyOfficeExt
u.DisableOfficePreviewExt = d.FileViewer.DisableOnlyOfficeExt // deprecated field, map to same source
u.PreferEditorForMarkdown = d.FileViewer.PreferEditorForMarkdown
u.DebugOffice = d.FileViewer.DebugOffice
// Search settings
u.DisableSearchOptions = d.Search.DisableOptions
// UI settings
u.DarkMode = boolValueOrDefault(d.UI.DarkMode, true)
u.ThemeColor = d.UI.ThemeColor
u.CustomTheme = d.UI.CustomTheme
u.Locale = d.UI.Locale
// FileLoading settings
u.FileLoading = d.FileLoading
if u.Username == "anonymous" {
return
}
// Permissions
u.Permissions.Api = d.Account.Permissions.Api
u.Permissions.Admin = d.Account.Permissions.Admin
u.Permissions.Modify = d.Account.Permissions.Modify
u.Permissions.Share = d.Account.Permissions.Share
u.Permissions.Realtime = d.Account.Permissions.Realtime
u.Permissions.Delete = d.Account.Permissions.Delete
u.Permissions.Create = d.Account.Permissions.Create
u.Permissions.Download = boolValueOrDefault(d.Account.Permissions.Download, true)
if u.LoginMethod == "" && d.Account.LoginMethod != "" {
u.LoginMethod = users.LoginMethod(d.Account.LoginMethod)
}
if len(u.Scopes) == 0 {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
u.Scopes = append(u.Scopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: source.Config.DefaultUserScope,
})
u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{
Name: source.Name,
Category: "source",
Target: "/",
Icon: "",
SourceName: source.Path,
})
}
}
}
u.Version = users.CurrentUserMigrationVersion
}
-35
View File
@@ -1,35 +0,0 @@
server:
numImageProcessors: 4
socket: ""
tlsKey: ""
tlsCert: ""
disablePreviews: false
disablePreviewResize: false
port: 80
baseURL: "/"
database: "mydb.db"
sources:
- path: "."
integrations:
office:
url: "http://localhost:9052"
secret: 6H1H7GzUk2alRkNxAPav0W02g8JS5oUd
auth:
methods:
password:
enabled: true
frontend:
name: ""
disableUsedPercentage: true
userDefaults:
locale: ""
viewMode: ""
darkMode: true
disableSettings: false
singleClick: true
permissions:
admin: true
modify: true
share: true
api: true
dateFormat: false
-74
View File
@@ -1,74 +0,0 @@
package utils
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"golang.org/x/crypto/bcrypt"
)
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return nil, err
}
return b, nil
}
// RandomHex returns a hex-encoded string from byteLen bytes of crypto/rand (length 2*byteLen).
func RandomHex(byteLen int) (string, error) {
b, err := randomBytes(byteLen)
if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
var BcryptCost = bcrypt.DefaultCost
var InvalidPasswordHash = ""
// HashPwd hashes a password.
func HashPwd(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
return string(bytes), err
}
// CheckPwd checks if a password is correct.
func CheckPwd(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
func SetInvalidPasswordHash() error {
passwordHash, err := HashPwd(InsecureRandomIdentifier(16))
if err != nil {
return err
}
InvalidPasswordHash = passwordHash
return nil
}
func HashSHA256(data string) string {
bytes := sha256.Sum256([]byte(data))
return hex.EncodeToString(bytes[:])
}
func GenerateKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return ""
}
return string(b)
}
// CSPNonce returns a base64-encoded random value suitable for Content-Security-Policy nonces
// and matching HTML nonce="" attributes (cryptographically random, URL/header safe characters).
func CSPNonce() (string, error) {
b, err := randomBytes(16)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(b), nil
}
-10
View File
@@ -1,10 +0,0 @@
userDefaults:
preview:
image: true
video: true
audio: true
motionVideoPreview: true
office: true
popup: true
folder: true
models: true
+50 -41
View File
@@ -1,6 +1,7 @@
server:
http:
port: 8080
baseURL: "/"
server:
database: "playwright-files.db"
logging:
- levels: "info|warning|debug|error"
@@ -8,48 +9,56 @@ server:
- path: "../frontend/playwright-files"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: false
create: false
delete: false
userDefaults:
editorQuickSave: false # show quick save button in editor
hideSidebarFileActions: false # hide the file actions in the sidebar
disableQuickToggles: false # disable the quick toggles in the sidebar
disableSearchOptions: false # disable the search options in the search bar
stickySidebar: true # keep sidebar open when navigating
darkMode: true # should dark mode be enabled
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
singleClick: false # open directory on single click, also enables middle click to open in new tab
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
quickDownload: false # show icon to download in one click
disablePreviewExt: "" # space separated list of file extensions to disable preview for
disableViewingExt: "" # space separated list of file extensions to disable viewing for
lockPassword: false # disable the user from changing their password
disableSettings: false # disable the user from viewing the settings page
fileViewer:
editorQuickSave: false
disableOnlyOfficeExt: ".md .txt .pdf"
disableViewingExt: ""
sidebar:
hideFileActions: false
disableQuickToggles: false
sticky: true
disableHideOnPreview: true
search:
disableOptions: false
ui:
darkMode: true
themeColor: "var(--blue)"
locale: ""
listing:
singleClick: false
showHidden: false
dateFormat: false
viewMode: "normal"
gallerySize: 3
quickDownload: false
showSelectMultiple: false
deleteWithoutConfirming: false
preview:
disableHideSidebar: true # disable the hide sidebar preview for previews and editors
image: true # show thumbnail preview image for image files
video: true # show thumbnail preview image for video files
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
office: true # show thumbnail preview image for office files
popup: true # show larger popup preview when hovering over thumbnail
autoplayMedia: true # autoplay media files in preview
defaultMediaPlayer: true # disable html5 media player and use the default media player
folder: true # show thumbnail preview image for folder files
permissions:
api: false # allow api access
admin: false # allow admin access
modify: false # allow modifying files
share: false # allow sharing files
realtime: false # allow realtime updates
delete: false # allow deleting files
create: false # allow creating or uploading files
download: true # allow downloading files
disableUpdateNotifications: false # disable update notifications banner for admin users
deleteWithoutConfirming: false # delete files without confirmation
disableOnlyOfficeExt: ".md .txt .pdf" # list of file extensions to disable onlyoffice editor for
showSelectMultiple: false # show select multiple files on desktop
image: true
video: true
motionVideoPreview: true
office: true
popup: true
folder: true
disablePreviewExt: ""
account:
lockPassword: false
disableSettings: false
disableUpdateNotifications: false
permissions:
api: false
admin: false
share: false
realtime: false
#office:
# url: "http://localhost:9052"
# secret: "6H1H7GzUk2alRkNxAPav0W02g8JS5oUd"
# secret: "6H1H7GzUk2alRkNxAPav0W02g8JS5oUd"
+4 -75
View File
@@ -1,13 +1,13 @@
server:
http:
port: 80
baseURL: "/"
server:
logging:
- levels: "info|warning|error"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "/srv"
auth:
key: "change-me-to-a-random-string"
totpSecret: "change-me-to-another-random-string"
methods:
password:
enabled: true
@@ -15,74 +15,3 @@ auth:
signup: false
passkey:
enabled: false
userDefaults:
sidebar:
disableQuickToggles: false # disable the quick toggles in the sidebar
hideFileActions: false # hide the file actions in the sidebar
disableHideOnPreview: false # keep sidebar open when previewing files
sticky: true # keep sidebar open when navigating
hideFiles: false # hide files in the sidebar tree navigation, when true, will show only directories
showTools: true # show sidebar links of category "tool"; when false they stay in the list but are hidden in the UI
listing:
deleteWithoutConfirming: false # delete files without confirmation
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
quickDownload: false # show icon to download in one click
showSelectMultiple: false # show select multiple files on desktop
singleClick: false # open directory on single click, also enables middle click to open in new tab
hideFileExt: "" # space separated list of file extensions to hide in UI
showCopyPath: false # show copy path button in the context menu
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
preview:
image: true # show thumbnail preview image for image files (defaults to true if not set)
video: true # show thumbnail preview image for video files (defaults to true if not set)
audio: true # show thumbnail preview image for audio files (defaults to true if not set)
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
office: true # show thumbnail preview image for office files (defaults to true if not set)
popup: true # show larger popup preview when hovering over thumbnail (defaults to true if not set)
disablePreviewExt: "" # space separated list of file extensions to disable preview for
highQuality: true # high quality preview thumbnails
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
fileViewer:
editorQuickSave: false # show quick save button in editor
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
disableViewingExt: "" # space separated list of file extensions to disable viewing for
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
debugOffice: false # debug onlyoffice editor
defaultMediaPlayer: false # disable html5 media player and use the default media player
search:
disableOptions: false # disable the search options in the search bar
ui:
darkMode: true # should dark mode be enabled (defaults to true if not set)
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
customTheme: "" # Name of theme to use chosen from custom themes config
locale: "en" # language to use: eg. de, en, or fr
fileLoading:
maxConcurrentUpload: 10 # maximum number of concurrent uploads
uploadChunkSizeMb: 10 # upload chunk size in MB
downloadChunkSizeMb: 0 # download chunk size in MB (0 = no chunking)
account:
permissions:
api: false # allow api access
admin: false # allow admin access
modify: false # allow modifying files
share: false # allow sharing files
realtime: false # allow realtime updates
delete: false # allow deleting files
create: false # allow creating or uploading files
download: true # allow downloading files (defaults to true if not set)
lockPassword: false # disable the user from changing their password
disableSettings: false # disable the user from viewing the settings page
loginMethod: "password" # login method to use: eg. password, proxy, oidc
disableUpdateNotifications: false # disable update notifications banner for admin users
-76
View File
@@ -1,76 +0,0 @@
package share
import (
"sync"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type CommonShare struct {
DownloadsLimit int `json:"downloadsLimit,omitempty"`
ShareTheme string `json:"shareTheme,omitempty"`
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
MaxBandwidth int `json:"maxBandwidth,omitempty"`
DisableThumbnails bool `json:"disableThumbnails,omitempty"`
KeepAfterExpiration bool `json:"keepAfterExpiration,omitempty"`
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
ThemeColor string `json:"themeColor,omitempty"`
Banner string `json:"banner,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Favicon string `json:"favicon,omitempty"`
QuickDownload bool `json:"quickDownload,omitempty"`
HideNavButtons bool `json:"hideNavButtons,omitempty"`
DisableSidebar bool `json:"disableSidebar"`
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
Path string `json:"path,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
ShareURL string `json:"shareURL,omitempty"`
FaviconUrl string `json:"faviconUrl,omitempty"`
BannerUrl string `json:"bannerUrl,omitempty"`
DisableShareCard bool `json:"disableShareCard,omitempty"`
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
EnableOnlyOffice bool `json:"enableOnlyOffice,omitempty"`
ShareType string `json:"shareType"` // type of share: normal, upload, max
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"` // can be io intensive for large files and take 10-30 seconds.
AllowDelete bool `json:"allowDelete,omitempty"`
AllowCreate bool `json:"allowCreate,omitempty"` // allow creating files
AllowModify bool `json:"allowModify,omitempty"` // allow modifying files
DisableFileViewer bool `json:"disableFileViewer,omitempty"` // don't allow viewing files
DisableDownload bool `json:"disableDownload,omitempty"` // don't allow downloading files
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HasPassword bool `json:"hasPassword,omitempty"`
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
HideFileExt string `json:"hideFileExt,omitempty"` // show hidden files based on extensions in shares
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
SourceURL string `json:"sourceURL,omitempty"`
CanEditShare bool `json:"canEditShare,omitempty"`
}
type CreateBody struct {
CommonShare
Hash string `json:"hash,omitempty"`
Password string `json:"password"`
Expires string `json:"expires"`
Unit string `json:"unit"`
}
// Link is the information needed to build a shareable link.
type Link struct {
CommonShare
Downloads int `json:"downloads"`
Hash string `json:"hash" storm:"id,index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
// Token is a random value that will only be set when PasswordHash is set. It is
// URL-Safe and is used to download links in password-protected shares via a
// query arg.
Token string `json:"token,omitempty"`
Mu sync.Mutex `json:"-"`
UserDownloads map[string]int `json:"userDownloads,omitempty"` // Track downloads per username
Version int `json:"version,omitempty"`
}
-409
View File
@@ -1,409 +0,0 @@
package share
import (
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// StorageBackend is the interface to implement for a share storage.
type StorageBackend interface {
All() ([]*Link, error)
FindByUserID(id uint) ([]*Link, error)
GetByHash(hash string) (*Link, error)
GetCommonShareByHash(hash string) (*CommonShare, error)
GetPermanent(path, source string, id uint) (*Link, error)
GetBySourcePath(path, source string) ([]*Link, error)
Gets(path, source string, id uint) ([]*Link, error)
Save(s *Link) error
Delete(hash string) error
}
// crudBackend implements crud.CrudBackend[Link] for share storage.
type crudBackend struct {
back StorageBackend
}
func (c *crudBackend) GetByID(id any) (*Link, error) {
hash, ok := id.(string)
if !ok {
return nil, errors.ErrInvalidDataType
}
return c.back.GetByHash(hash)
}
func (c *crudBackend) GetAll() ([]*Link, error) {
return c.back.All()
}
func (c *crudBackend) Save(obj *Link) error {
return c.back.Save(obj)
}
func (c *crudBackend) DeleteByID(id any) error {
hash, ok := id.(string)
if !ok {
return errors.ErrInvalidDataType
}
return c.back.Delete(hash)
}
// Storage is a share storage using generics.
type Storage struct {
Generic *crud.Storage[Link]
back StorageBackend
shareByHash map[string]*Link // key: link hash
shareByPath map[string]map[string]string // key: pathKey(Source, Path), value: set of hashes (hash -> "")
mu sync.RWMutex
users *users.Storage
}
// pathKey returns the cache key for shareByPath (source + path).
func pathKey(source, path string) string {
return source + ":" + path
}
// setCacheLocked updates both shareByHash and shareByPath for the given link.
// Caller must hold s.mu.
func (s *Storage) setCacheLocked(link *Link) {
if link == nil {
return
}
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
s.shareByHash[link.Hash] = link
key := pathKey(adjustedSource, adjustedPath)
if s.shareByPath[key] == nil {
s.shareByPath[key] = make(map[string]string)
}
s.shareByPath[key][link.Hash] = ""
}
// setCache updates both caches for the given link. It acquires s.mu.
func (s *Storage) setCache(link *Link) {
s.mu.Lock()
s.setCacheLocked(link)
s.mu.Unlock()
}
// deleteFromCacheLocked removes the link (by hash) from both caches.
// Caller must hold s.mu.
func (s *Storage) deleteFromCacheLocked(hash string) {
link, ok := s.shareByHash[hash]
if !ok {
return
}
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
key := pathKey(adjustedSource, adjustedPath)
if inner, ok := s.shareByPath[key]; ok {
delete(inner, hash)
if len(inner) == 0 {
delete(s.shareByPath, key)
}
}
delete(s.shareByHash, hash)
}
// deleteFromCache removes the link (by hash) from both caches. It acquires s.mu.
func (s *Storage) deleteFromCache(hash string) {
s.mu.Lock()
s.deleteFromCacheLocked(hash)
s.mu.Unlock()
}
// setCacheAfterMove updates both caches when a link's Source or Path changed. It acquires s.mu.
func (s *Storage) setCacheAfterMove(link *Link, oldSource, oldPath string) {
s.mu.Lock()
if link == nil {
return
}
adjustedOldPath := utils.AddTrailingSlashIfNotExists(oldPath)
adjustedOldSource := utils.AddTrailingSlashIfNotExists(oldSource)
oldKey := pathKey(adjustedOldSource, adjustedOldPath)
if inner, ok := s.shareByPath[oldKey]; ok {
delete(inner, link.Hash)
if len(inner) == 0 {
delete(s.shareByPath, oldKey)
}
}
s.setCacheLocked(link)
s.mu.Unlock()
}
// NewStorage creates a share links storage from a backend and populates the
// in-memory cache from the database so all reads can be served from cache.
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
s := &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareByHash: make(map[string]*Link),
shareByPath: make(map[string]map[string]string),
users: usersStore,
}
s.loadShareCache()
return s
}
// loadShareCache fills shareByHash and shareByPath from the backend and removes expired links from the DB.
// Call once at startup so all reads can be served from cache.
func (s *Storage) loadShareCache() {
links, err := s.back.All()
if err != nil && err != errors.ErrNotExist {
return
}
if links == nil {
links = []*Link{}
}
s.mu.Lock()
for _, link := range links {
if link == nil {
continue
}
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
continue
}
link.InitUserDownloads()
s.setCacheLocked(link)
}
s.mu.Unlock()
}
// LoadShareCacheFromDB repopulates the in-memory cache from the database.
// Call at startup (e.g. from InitializeDb) to ensure shares are loaded after the store is ready.
func (s *Storage) LoadShareCacheFromDB() {
s.loadShareCache()
}
// All returns all non-expired shares from the cache (populated at startup and by writes).
func (s *Storage) All() ([]*Link, error) {
s.mu.Lock()
result := make([]*Link, 0, len(s.shareByHash))
for _, l := range s.shareByHash {
if l == nil {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
}
// FindByUserID returns non-expired shares for the user from the cache.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
s.mu.Lock()
result := make([]*Link, 0)
for _, l := range s.shareByHash {
if l == nil || l.UserID != id {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
}
// GetByHash wraps StorageBackend.GetByHash and handles expiry.
func (s *Storage) GetByHash(hash string) (*Link, error) {
// return stable in-memory pointer if available
s.mu.RLock()
if link, ok := s.shareByHash[hash]; ok && link != nil {
s.mu.RUnlock()
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
s.deleteFromCache(hash)
return nil, errors.ErrNotExist
}
return link, nil
}
s.mu.RUnlock()
link, err := s.back.GetByHash(hash)
if err != nil {
return nil, err
}
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
return nil, errors.ErrNotExist
}
// Initialize UserDownloads map
link.InitUserDownloads()
s.setCache(link)
return link, nil
}
// GetPermanent wraps StorageBackend.GetPermanent
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
l, err := s.back.GetPermanent(path, source, id)
if err == nil && l != nil {
s.setCache(l)
}
return l, err
}
// Gets returns shares for the given path, source, and user from the cache.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
s.mu.Lock()
adjustedPath := utils.AddTrailingSlashIfNotExists(sourcePath)
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
key := pathKey(adjustedSource, adjustedPath)
hashes := s.shareByPath[key]
result := make([]*Link, 0, len(hashes))
for h := range hashes {
l := s.shareByHash[h]
if l == nil || l.UserID != id {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
}
// GetBySourcePath returns shares for the given path and source from the cache.
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
s.mu.Lock()
adjustedPath := utils.AddTrailingSlashIfNotExists(path)
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
key := pathKey(adjustedSource, adjustedPath)
hashes := s.shareByPath[key]
result := make([]*Link, 0, len(hashes))
for h := range hashes {
l := s.shareByHash[h]
if l == nil {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
}
// IsShared returns whether the given path and source have any shares in the cache.
func (s *Storage) IsShared(path, source string, id uint) bool {
links, _ := s.GetBySourcePath(path, source)
for _, l := range links {
if l.UserID == id {
return true
}
}
return len(links) > 0
}
// UpdateShares updates all shares that match oldSource and oldPath to point to newSource and newPath.
// Handles both exact matches and subdirectories, regardless of trailing slashes.
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
links, err := s.All()
if err != nil && err != errors.ErrNotExist {
logger.Error("failed to list shares", "error", err)
return 0, err
}
// Normalize paths for comparison (remove trailing slashes)
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
newPath = utils.AddTrailingSlashIfNotExists(newPath)
updated := 0
for _, l := range links {
if l == nil || l.Source != oldSource {
continue
}
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
pos := strings.Index(l.Path, oldPath)
if pos < 0 {
continue
}
l.Source = newSource
l.Path = newPath
if err := s.back.Save(l); err != nil {
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
return updated, err
}
s.setCacheAfterMove(l, oldSource, oldPath)
updated++
}
return updated, nil
}
// UpdateSharePath updates the path for a specific share identified by hash
func (s *Storage) UpdateSharePath(hash, newPath string) error {
link, err := s.GetByHash(hash)
if err != nil {
return err
}
oldPath := link.Path
link.Path = newPath
if err := s.back.Save(link); err != nil {
logger.Error("failed to save updated share", "hash", hash, "error", err)
return err
}
s.setCacheAfterMove(link, link.Source, oldPath)
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
return nil
}
// Save wraps StorageBackend.Save
func (s *Storage) Save(l *Link) error {
if err := s.back.Save(l); err != nil {
return err
}
s.setCache(l)
return nil
}
// Delete wraps StorageBackend.Delete
func (s *Storage) Delete(hash string) error {
if err := s.back.Delete(hash); err != nil {
return err
}
s.deleteFromCache(hash)
return nil
}
// Flush is a no-op: every share write (Save, Delete, UpdateShares, UpdateSharePath) already
// updates the database, so the cache and DB stay in sync without flushing.
func (s *Storage) Flush() error {
return nil
}
// GetCommonShareByHash retrieves only the CommonShare attributes for a given hash
func (s *Storage) GetCommonShareByHash(hash string) (*CommonShare, error) {
return s.back.GetCommonShareByHash(hash)
}
-30
View File
@@ -1,30 +0,0 @@
package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
type authBackend struct {
db *storm.DB
}
func (s authBackend) Get(t string) (auth.Auther, error) {
var auther auth.Auther
switch t {
case "password":
auther = &auth.JSONAuth{}
case "proxy":
auther = &auth.ProxyAuth{}
case "noauth":
auther = &auth.NoAuth{}
default:
return nil, errors.ErrInvalidAuthMethod
}
return auther, get(s.db, "auther", auther)
}
func (s authBackend) Save(a auth.Auther) error {
return Save(s.db, "auther", a)
}
-40
View File
@@ -1,40 +0,0 @@
package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// Storage is a storage powered by a Backend which makes the necessary
// verifications when fetching and saving data to ensure consistency.
type BoltStore struct {
Users *users.Storage
Share *share.Storage
Auth *auth.Storage
Settings *settings.Storage
Access *access.Storage
Indexing *dbindex.Storage
}
// NewStorage creates a storage.Storage based on Bolt DB.
func NewStorage(db *storm.DB) (*BoltStore, error) {
userStore := users.NewStorage(usersBackend{db: db})
authStore, err := auth.NewStorage(authBackend{db: db}, userStore)
if err != nil {
return nil, err
}
return &BoltStore{
Users: userStore,
Share: share.NewStorage(shareBackend{db: db}, userStore),
Auth: authStore,
Settings: settings.NewStorage(settingsBackend{db: db}),
Access: access.NewStorage(db, userStore),
Indexing: dbindex.NewStorage(indexingBackend{db: db}),
}, nil
}
-31
View File
@@ -1,31 +0,0 @@
package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
type settingsBackend struct {
db *storm.DB
}
func (s settingsBackend) Get() (*settings.Settings, error) {
set := &settings.Settings{}
return set, get(s.db, "settings", set)
}
func (s settingsBackend) Save(set *settings.Settings) error {
return Save(s.db, "settings", set)
}
func (s settingsBackend) GetServer() (*settings.Server, error) {
server := &settings.Server{
Port: 80,
NumImageProcessors: 1,
}
return server, get(s.db, "server", server)
}
func (s settingsBackend) SaveServer(server *settings.Server) error {
return Save(s.db, "server", server)
}
-55
View File
@@ -1,55 +0,0 @@
package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
)
type indexingBackend struct {
db *storm.DB
}
func (s indexingBackend) All() ([]*dbindex.IndexInfo, error) {
var v []*dbindex.IndexInfo
err := s.db.All(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
}
return v, err
}
func (s indexingBackend) GetByPath(path string) (*dbindex.IndexInfo, error) {
var v dbindex.IndexInfo
err := s.db.One("Path", path, &v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return &v, err
}
func (s indexingBackend) GetBySource(source string) ([]*dbindex.IndexInfo, error) {
var v []*dbindex.IndexInfo
err := s.db.Select(q.Eq("Source", source)).Find(&v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return v, err
}
func (s indexingBackend) Save(info *dbindex.IndexInfo) error {
return s.db.Save(info)
}
func (s indexingBackend) Delete(path string) error {
err := s.db.DeleteStruct(&dbindex.IndexInfo{Path: path})
if err == storm.ErrNotFound {
return nil
}
return err
}
-111
View File
@@ -1,111 +0,0 @@
package bolt
import (
"time"
storm "github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/go-logger/logger"
)
type shareBackend struct {
db *storm.DB
}
func (s shareBackend) All() ([]*share.Link, error) {
var v []*share.Link
err := s.db.All(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
}
return v, err
}
func (s shareBackend) FindByUserID(id uint) ([]*share.Link, error) {
var v []*share.Link
err := s.db.Select(q.Eq("UserID", id)).Find(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
}
return v, err
}
func (s shareBackend) GetByHash(hash string) (*share.Link, error) {
var v share.Link
err := s.db.One("Hash", hash, &v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return &v, err
}
func (s shareBackend) GetCommonShareByHash(hash string) (*share.CommonShare, error) {
var v share.Link
err := s.db.One("Hash", hash, &v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
if err != nil {
return nil, err
}
v.Source = ""
v.Path = ""
v.AllowedUsernames = nil
v.DownloadsLimit = 0
v.CommonShare.HasPassword = v.HasPassword()
return &v.CommonShare, nil
}
func (s shareBackend) GetPermanent(path, source string, id uint) (*share.Link, error) {
var v share.Link
// TODO remove legacy and return notfound errors
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", source), q.Eq("Expire", 0), q.Eq("UserID", id)).First(&v)
return &v, nil
}
// GetBySourcePath returns all shares that exactly match Path and Source across users.
func (s shareBackend) GetBySourcePath(path, source string) ([]*share.Link, error) {
var v []*share.Link
err := s.db.Select(q.Eq("Path", path), q.Eq("Source", source)).Find(&v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return v, err
}
func (s shareBackend) Gets(path, sourcePath string, id uint) ([]*share.Link, error) {
var v []*share.Link
_ = s.db.Select(q.Eq("Path", path), q.Eq("Source", sourcePath), q.Eq("UserID", id)).Find(&v)
filteredList := []*share.Link{}
var err error
// through and filter out expired share
for i := range v {
if v[i].Expire < time.Now().Unix() && v[i].Expire != 0 {
err = s.Delete(v[i].Hash)
if err != nil {
logger.Errorf("expired share could not be deleted: %v", err.Error())
}
} else {
filteredList = append(filteredList, v[i])
}
}
return filteredList, err
}
func (s shareBackend) Save(l *share.Link) error {
return s.db.Save(l)
}
func (s shareBackend) Delete(hash string) error {
err := s.db.DeleteStruct(&share.Link{Hash: hash})
if err == storm.ErrNotFound {
return nil
}
return err
}
-327
View File
@@ -1,327 +0,0 @@
package bolt
import (
"fmt"
"reflect"
"slices"
"strings"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
type usersBackend struct {
db *storm.DB
}
// NewUsersBackend returns a users.StorageBackend backed by storm DB.
func NewUsersBackend(db *storm.DB) users.StorageBackend {
return &usersBackend{db: db}
}
func (st usersBackend) GetBy(i interface{}) (user *users.User, err error) {
user = &users.User{}
var arg string
var val interface{}
switch i := i.(type) {
case uint:
val = i
arg = "ID"
case int:
val = uint(i)
arg = "ID"
case string:
arg = "Username"
val = i
default:
return nil, errors.ErrInvalidDataType
}
err = st.db.One(arg, val, user)
if err != nil {
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return nil, err
}
return
}
func (st usersBackend) Gets() ([]*users.User, error) {
var allUsers []*users.User
err := st.db.All(&allUsers)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
if err != nil {
return allUsers, err
}
return allUsers, err
}
func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...string) error {
existingUser, err := st.GetBy(user.ID)
if err != nil {
return err
}
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
fields, err = parseFields(user, fields, actorIsAdmin)
if err != nil {
return err
}
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
if user.TOTPNonce == "" {
user.TOTPNonce = existingUser.TOTPNonce
}
if user.TOTPSecret == "" {
user.TOTPSecret = existingUser.TOTPSecret
}
if user.LoginMethod == users.LoginMethodPassword && enforcedOtp && !user.OtpEnabled {
if slices.Contains(fields, "OtpEnabled") {
return errors.ErrNoTotpConfigured
}
}
if !slices.Contains(fields, "Password") {
user.Password = existingUser.Password
} else {
if existingUser.LockPassword && !actorIsAdmin {
return fmt.Errorf("password cannot be changed when lock password is enabled")
}
}
if !actorIsAdmin {
fields = filterRestrictedFields(fields)
}
if len(fields) == 0 {
return fmt.Errorf("no fields to update")
}
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "Scopes") {
adjustedScopes, err := user.GetBackendScopes()
if err != nil {
return err
}
for _, scope := range adjustedScopes {
// ensure full path exists
if scope.Scope == "" {
return fmt.Errorf("scope name is required")
}
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, false)
if err != nil {
return err
}
}
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "SidebarLinks") {
adjustedLinks, err := user.GetBackendSidebarLinks()
if err != nil {
return err
}
user.SidebarLinks = adjustedLinks
}
// Use reflection to access struct fields
userFields := reflect.ValueOf(user).Elem() // Get struct value
for _, field := range fields {
// Get the corresponding field using reflection
fieldValue := userFields.FieldByName(field)
if !fieldValue.IsValid() {
return fmt.Errorf("invalid field: %s", field)
}
// Ensure the field is settable
if !fieldValue.CanSet() {
return fmt.Errorf("cannot set value of field: %s", field)
}
// Get the value to be stored
val := fieldValue.Interface()
if field == "OtpEnabled" {
otpEnabled, _ := val.(bool)
if !otpEnabled {
field = "TOTPSecret" // if otp is disabled, we also want to clear the TOTPSecret
val = "" // clear the TOTPSecret
}
// If otpEnabled is true, continue with normal field update
}
// Update the database
if err := st.db.UpdateField(existingUser, field, val); err != nil {
return fmt.Errorf("failed to update user field: %s, error: %v", field, err)
}
}
return nil
}
func (st usersBackend) Save(user *users.User, changePass, disableScopeChange bool) error {
if user.LoginMethod == "" {
user.LoginMethod = users.LoginMethodPassword
}
if user.Username == "anonymous" {
return fmt.Errorf("username cannot be 'anonymous'")
}
logger.Debugf("Saving user [%s] changepass: %v", user.Username, changePass)
if user.LoginMethod == users.LoginMethodPassword && changePass {
err := checkPassword(user.Password)
if err != nil {
return err
}
pass, err := utils.HashPwd(user.Password)
if err != nil {
return err
}
user.Password = pass
}
// converting scopes to map of paths intead of names (names can change)
adjustedScopes, err := user.GetBackendScopes()
if err != nil {
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, true)
if err != nil {
logger.Error(err.Error())
}
err = st.db.Save(user)
if err == storm.ErrAlreadyExists {
return fmt.Errorf("user with provided username already exists")
}
return err
}
func (st usersBackend) DeleteByID(id uint) error {
return st.db.DeleteStruct(&users.User{ID: id})
}
func (st usersBackend) DeleteByUsername(username string) error {
user, err := st.GetBy(username)
if err != nil {
return err
}
return st.db.DeleteStruct(user)
}
// Define a function to filter out restricted fields for non-admin users
func filterRestrictedFields(fields []string) []string {
// Get a list of allowed fields from NonAdminEditable
allowed := getNonAdminEditableFieldNames()
var filteredFields []string
for _, field := range fields {
if slices.Contains(allowed, field) {
filteredFields = append(filteredFields, field)
}
}
return filteredFields
}
// Helper to return list of field names from NonAdminEditable struct
func getNonAdminEditableFieldNames() []string {
var names []string
t := reflect.TypeOf(users.NonAdminEditable{})
for i := 0; i < t.NumField(); i++ {
names = append(names, t.Field(i).Name)
}
return names
}
func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string, error) {
// If `Which` is not specified, default to updating all fields
if len(fields) == 0 || fields[0] == "all" {
fields = []string{}
v := reflect.ValueOf(user)
if v.Kind() == reflect.Pointer {
v = v.Elem()
}
t := v.Type()
// Dynamically populate fields to update
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// which=all can't update password
switch strings.ToLower(field.Name) {
case "id", "username", "password", "tokens", "totpsecret", "totpnonce":
// Skip these fields
continue
}
// Handle embedded structs (like NonAdminEditable)
if field.Anonymous {
// Get the embedded struct type
embeddedType := field.Type
if embeddedType.Kind() == reflect.Pointer {
embeddedType = embeddedType.Elem()
}
// Add all fields from the embedded struct
for j := 0; j < embeddedType.NumField(); j++ {
embeddedField := embeddedType.Field(j)
fields = append(fields, embeddedField.Name)
}
} else {
fields = append(fields, field.Name)
}
}
}
newfields := []string{}
for _, field := range fields {
capitalField := utils.CapitalizeFirst(field)
if capitalField == "Scopes" {
if !actorIsAdmin {
continue
}
}
if capitalField == "Password" {
// Only process password if it's actually being updated (not empty)
if user.Password != "" {
logger.Debugf("Updating password for user [%s] loginMethod: %s", user.Username, user.LoginMethod)
if user.LoginMethod != users.LoginMethodPassword {
return nil, fmt.Errorf("password cannot be changed when login method is not password")
}
err := checkPassword(user.Password)
if err != nil {
return nil, fmt.Errorf("password does not meet complexity requirements")
}
value, err := utils.HashPwd(user.Password)
if err != nil {
logger.Error(err.Error())
}
user.Password = value
} else {
// Skip password field if it's empty
continue
}
}
newfields = append(newfields, capitalField)
}
return newfields, nil
}
func checkPassword(password string) error {
if len(password) < settings.Config.Auth.Methods.PasswordAuth.MinLength {
return fmt.Errorf("password must be at least %d characters long", settings.Config.Auth.Methods.PasswordAuth.MinLength)
}
return nil
}
-530
View File
@@ -1,530 +0,0 @@
package bolt
import (
"path/filepath"
"testing"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
func init() {
utils.BcryptCost = 4 // bcrypt.MinCost for faster tests
// Initialize test source configuration
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/tmp/test": {
Path: "/tmp/test",
Name: "test",
Config: settings.SourceConfig{
DefaultUserScope: "/",
},
},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"test": settings.Config.Server.SourceMap["/tmp/test"],
}
settings.Config.Server.Sources = []*settings.Source{
settings.Config.Server.SourceMap["/tmp/test"],
}
// Don't set default scopes for tests - let tests explicitly set scopes when needed
settings.Config.UserDefaults.DefaultScopes = []users.SourceScope{}
// Initialize user package resolvers
settings.InitializeUserResolvers()
}
func createTestUsersBackend(t *testing.T) users.StorageBackend {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := storm.Open(dbPath)
if err != nil {
t.Fatalf("failed to open storm db: %v", err)
}
return NewUsersBackend(db)
}
func createTestUser(t *testing.T, backend users.StorageBackend, username string, isAdmin bool) *users.User {
user := &users.User{
Username: username,
NonAdminEditable: users.NonAdminEditable{
Password: "testpass123",
DarkMode: false,
OtpEnabled: true,
Locale: "en",
ViewMode: "normal",
SingleClick: false,
},
Permissions: users.Permissions{
Admin: isAdmin,
},
LoginMethod: users.LoginMethodPassword,
}
err := backend.Save(user, true, false)
if err != nil {
t.Fatalf("failed to create user %s: %v", username, err)
}
return user
}
func TestUpdate_AdminModifyOtherUser(t *testing.T) {
backend := createTestUsersBackend(t)
// Create admin user
_ = createTestUser(t, backend, "admin", true)
// Create regular user
regularUser := createTestUser(t, backend, "regular", false)
// Admin modifies regular user
updateData := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
OtpEnabled: false,
Locale: "fr",
},
Permissions: users.Permissions{
Admin: false, // Admin can modify permissions
},
}
// Test with which=all
err := backend.Update(updateData, true, "all")
if err != nil {
t.Errorf("Admin should be able to modify other user with which=all: %v", err)
}
// Verify changes were applied
updatedUser, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser.DarkMode {
t.Error("DarkMode should have been updated to true")
}
if updatedUser.OtpEnabled {
t.Error("OtpEnabled should have been updated to false")
}
if updatedUser.Locale != "fr" {
t.Errorf("Locale should have been updated to 'fr', got '%s'", updatedUser.Locale)
}
// Test with specific fields
updateData2 := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
ViewMode: "list",
},
}
err = backend.Update(updateData2, true, "ViewMode")
if err != nil {
t.Errorf("Admin should be able to modify specific field: %v", err)
}
updatedUser2, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if updatedUser2.ViewMode != "list" {
t.Errorf("ViewMode should have been updated to 'list', got '%s'", updatedUser2.ViewMode)
}
}
func TestUpdate_AdminModifySelf(t *testing.T) {
backend := createTestUsersBackend(t)
// Create admin user
admin := createTestUser(t, backend, "admin", true)
// Admin modifies themselves
updateData := &users.User{
ID: admin.ID,
Username: admin.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
OtpEnabled: false,
Locale: "de",
},
}
// Test with which=all
err := backend.Update(updateData, true, "all")
if err != nil {
t.Errorf("Admin should be able to modify themselves with which=all: %v", err)
}
// Verify changes were applied
updatedUser, err := backend.GetBy(admin.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser.DarkMode {
t.Error("DarkMode should have been updated to true")
}
if updatedUser.OtpEnabled {
t.Error("OtpEnabled should have been updated to false")
}
if updatedUser.Locale != "de" {
t.Errorf("Locale should have been updated to 'de', got '%s'", updatedUser.Locale)
}
}
func TestUpdate_NonAdminModifySelf(t *testing.T) {
backend := createTestUsersBackend(t)
// Create regular user
regularUser := createTestUser(t, backend, "regular", false)
// Non-admin modifies themselves
updateData := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
OtpEnabled: false, // This should be allowed now
Locale: "es",
ViewMode: "grid",
},
}
// Test with which=all
err := backend.Update(updateData, false, "all")
if err != nil {
t.Errorf("Non-admin should be able to modify themselves with which=all: %v", err)
}
// Verify changes were applied
updatedUser, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser.DarkMode {
t.Error("DarkMode should have been updated to true")
}
if updatedUser.OtpEnabled {
t.Error("OtpEnabled should have been updated to false")
}
if updatedUser.Locale != "es" {
t.Errorf("Locale should have been updated to 'es', got '%s'", updatedUser.Locale)
}
if updatedUser.ViewMode != "grid" {
t.Errorf("ViewMode should have been updated to 'grid', got '%s'", updatedUser.ViewMode)
}
// Test with specific fields
updateData2 := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
SingleClick: true,
},
}
err = backend.Update(updateData2, false, "SingleClick")
if err != nil {
t.Errorf("Non-admin should be able to modify specific allowed field: %v", err)
}
updatedUser2, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser2.SingleClick {
t.Error("SingleClick should have been updated to true")
}
}
func TestUpdate_NonAdminModifySelfRestrictedFields(t *testing.T) {
backend := createTestUsersBackend(t)
// Create regular user
regularUser := createTestUser(t, backend, "regular", false)
// Non-admin tries to modify restricted fields (should be filtered out, not error)
updateData := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true, // This should be allowed
},
Permissions: users.Permissions{
Admin: true, // This should be filtered out
},
Scopes: []users.SourceScope{ // This should be filtered out
{Name: "test", Scope: "/test"},
},
}
// Test with which=all - should not error, but should filter out restricted fields
err := backend.Update(updateData, false, "all")
if err != nil {
t.Errorf("Non-admin should not error when trying to modify restricted fields (should be filtered): %v", err)
}
// Verify only allowed fields were updated
updatedUser, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
// Allowed field should be updated
if !updatedUser.DarkMode {
t.Error("DarkMode should have been updated to true")
}
// Restricted fields should NOT be updated
if updatedUser.Permissions.Admin {
t.Error("Permissions.Admin should not have been updated (restricted field)")
}
if len(updatedUser.Scopes) != 0 {
t.Error("Scopes should not have been updated (restricted field)")
}
// Test with specific restricted fields - should be filtered out
updateData2 := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
Permissions: users.Permissions{
Admin: true,
},
}
err = backend.Update(updateData2, false, "Permissions")
if err == nil {
t.Error("Non-admin should get error when trying to modify only restricted fields (all fields filtered out)")
}
if err.Error() != "no fields to update" {
t.Errorf("Expected 'no fields to update' error, got: %v", err)
}
// Verify restricted field was not updated
updatedUser2, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if updatedUser2.Permissions.Admin {
t.Error("Permissions.Admin should not have been updated (restricted field)")
}
}
func TestUpdate_NonAdminModifyOtherUser(t *testing.T) {
backend := createTestUsersBackend(t)
// Create two regular users
_ = createTestUser(t, backend, "user1", false)
user2 := createTestUser(t, backend, "user2", false)
// Non-admin tries to modify another user (this should be handled at a higher level)
// But if it gets to the backend, it should work the same as modifying self
updateData := &users.User{
ID: user2.ID,
Username: user2.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
},
}
// This should work (filtering restricted fields) but the higher-level auth should prevent this
err := backend.Update(updateData, false, "DarkMode")
if err != nil {
t.Errorf("Backend should handle non-admin modifying other user (filtering restricted fields): %v", err)
}
// Verify the change was applied
updatedUser, err := backend.GetBy(user2.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser.DarkMode {
t.Error("DarkMode should have been updated to true")
}
}
func TestUpdate_NonAdminModifyOtherUserOtp(t *testing.T) {
backend := createTestUsersBackend(t)
// Create two regular users
_ = createTestUser(t, backend, "user1", false)
user2 := createTestUser(t, backend, "user2", false)
// Non-admin tries to modify another user's OTP
updateData := &users.User{
ID: user2.ID,
Username: user2.Username,
NonAdminEditable: users.NonAdminEditable{
OtpEnabled: false,
},
}
// This should work (OtpEnabled is now in NonAdminEditable)
err := backend.Update(updateData, false, "OtpEnabled")
if err != nil {
t.Errorf("Non-admin should be able to modify OTP field: %v", err)
}
// Verify the change was applied
updatedUser, err := backend.GetBy(user2.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if updatedUser.OtpEnabled {
t.Error("OtpEnabled should have been updated to false")
}
}
func TestUpdate_WhichAllVsSpecific(t *testing.T) {
backend := createTestUsersBackend(t)
// Create regular user
regularUser := createTestUser(t, backend, "regular", false)
// Test which=all
updateDataAll := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
OtpEnabled: false,
Locale: "fr",
ViewMode: "list",
},
}
err := backend.Update(updateDataAll, false, "all")
if err != nil {
t.Errorf("which=all should work: %v", err)
}
// Verify changes
updatedUser, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser.DarkMode || updatedUser.OtpEnabled || updatedUser.Locale != "fr" || updatedUser.ViewMode != "list" {
t.Error("which=all should update all allowed fields")
}
// Reset user
regularUser = createTestUser(t, backend, "regular2", false)
// Test specific fields
updateDataSpecific := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
Locale: "de",
},
}
err = backend.Update(updateDataSpecific, false, "DarkMode", "Locale")
if err != nil {
t.Errorf("specific fields should work: %v", err)
}
// Verify only specified fields were updated
updatedUser2, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser2.DarkMode {
t.Error("DarkMode should have been updated")
}
if updatedUser2.Locale != "de" {
t.Error("Locale should have been updated")
}
if updatedUser2.ViewMode != "normal" { // Should remain default
t.Error("ViewMode should not have been updated")
}
}
func TestUpdate_EmptyFieldsAfterFiltering(t *testing.T) {
backend := createTestUsersBackend(t)
// Create regular user
regularUser := createTestUser(t, backend, "regular", false)
// Non-admin tries to update only restricted fields
updateData := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
Permissions: users.Permissions{
Admin: true, // This will be filtered out
},
Scopes: []users.SourceScope{ // This will be filtered out
{Name: "test", Scope: "/test"},
},
}
// This should result in "no fields to update" error
err := backend.Update(updateData, false, "Permissions", "Scopes")
if err == nil {
t.Error("Should get error when all fields are filtered out")
}
if err.Error() != "no fields to update" {
t.Errorf("Expected 'no fields to update' error, got: %v", err)
}
}
func TestFilterRestrictedFields(t *testing.T) {
// Test the filterRestrictedFields function directly
testCases := []struct {
name string
fields []string
expected []string
}{
{
name: "all allowed fields",
fields: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
expected: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
},
{
name: "mixed allowed and restricted",
fields: []string{"DarkMode", "Permissions", "Scopes", "OtpEnabled"},
expected: []string{"DarkMode", "OtpEnabled"},
},
{
name: "all restricted fields",
fields: []string{"Permissions", "Scopes", "Username", "ID"},
expected: []string{},
},
{
name: "empty fields",
fields: []string{},
expected: []string{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := filterRestrictedFields(tc.fields)
if len(result) != len(tc.expected) {
t.Errorf("Expected %d fields, got %d", len(tc.expected), len(result))
}
for i, field := range result {
if field != tc.expected[i] {
t.Errorf("Expected field %s at position %d, got %s", tc.expected[i], i, field)
}
}
})
}
}
-33
View File
@@ -1,33 +0,0 @@
package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
func get(db *storm.DB, name string, to interface{}) error {
err := db.Get("config", name, to)
if err == storm.ErrNotFound {
return errors.ErrNotExist
}
return err
}
func Save(db *storm.DB, name string, from interface{}) error {
return db.Set("config", name, from)
}
func SaveAccessRules(db *storm.DB, name string, from interface{}) error {
return db.Set("access_rules", name, from)
}
func GetAccessRules(db *storm.DB, name string, to interface{}) error {
err := db.Get("access_rules", name, to)
if err == storm.ErrNotFound {
return errors.ErrNotExist
}
return err
}
-135
View File
@@ -1,135 +0,0 @@
package storage
import (
"fmt"
"os"
"path/filepath"
"strings"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
var userStore *users.Storage
func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
logger.Debugf("Initializing database: %v", path)
exists, err := dbExists(path)
if err != nil {
panic(err)
}
db, err := storm.Open(path)
if err != nil {
if strings.Contains(err.Error(), "timeout") {
logger.Fatal("the database is locked, please close all other instances of filebrowser before starting.")
}
logger.Fatalf("could not open database: %v", err)
}
store, err := bolt.NewStorage(db)
if err != nil {
return nil, exists, err
}
// Load access rules from DB on startup
// ignoring errors because
_ = store.Access.LoadFromDB()
// Load share cache from DB so shareByHash/shareByPath are populated at startup
store.Share.LoadShareCacheFromDB()
userStore = store.Users
err = bolt.Save(db, "version", 2)
if err != nil {
return nil, exists, err
}
if !exists {
if settings.Env.IsPlaywright || settings.Env.IsDevMode {
settings.Env.IsFirstLoad = false
} else {
settings.Env.IsFirstLoad = true
}
quickSetup(store)
}
return store, exists, err
}
func dbExists(path string) (bool, error) {
stat, err := os.Stat(path)
if err == nil {
return stat.Size() != 0, nil
}
if os.IsNotExist(err) {
d := filepath.Dir(path)
_, err = os.Stat(d)
if os.IsNotExist(err) {
if err = os.MkdirAll(d, fileutils.PermDir); err != nil {
return false, err
}
return false, nil
}
}
return false, err
}
func quickSetup(store *bolt.BoltStore) {
settings.Config.Auth.Key = utils.GenerateKey()
err := store.Settings.Save(&settings.Config)
utils.CheckErr("store.Settings.Save", err)
err = store.Settings.SaveServer(&settings.Config.Server)
utils.CheckErr("store.Settings.SaveServer", err)
passwordAuth := settings.Config.Auth.Methods.PasswordAuth.Enabled
noAuth := settings.Config.Auth.Methods.NoAuth
if passwordAuth || noAuth {
user := &users.User{}
settings.ApplyUserDefaults(user)
user.Username = settings.Config.Auth.AdminUsername
if settings.Config.Auth.AdminPassword == "" {
settings.Config.Auth.AdminPassword = "admin"
}
user.Password = settings.Config.Auth.AdminPassword
user.LockPassword = false
download := user.Permissions.Download
user.Permissions = settings.AdminPerms()
user.Permissions.Download = download
user.ShowFirstLogin = settings.Env.IsFirstLoad && user.Permissions.Admin
logger.Debugf("Creating user as admin: %v %v", user.Username, user.Password)
err = store.Users.Save(user, true, true)
utils.CheckErr("store.Users.Save", err)
}
}
// create new user
func CreateUser(userInfo users.User, permissions users.Permissions) error {
newUser := &userInfo
newUser.ShowFirstLogin = settings.Env.IsFirstLoad && newUser.Permissions.Admin
if userInfo.LoginMethod == "password" {
if userInfo.Password == "" {
return fmt.Errorf("password is required to create a password login user")
}
} else {
hashpass, err := utils.HashPwd(userInfo.Username)
if err != nil {
return err
}
userInfo.Password = hashpass
}
// must have username
if userInfo.Username == "" {
return fmt.Errorf("username is required to create a user")
}
settings.ApplyUserDefaults(newUser)
newUser.Permissions = permissions
newUser.Version = users.CurrentUserMigrationVersion
logger.Debugf("Creating user: %v %v", userInfo.Username, userInfo.Scopes)
// create new home directories
err := userStore.Save(newUser, true, false)
if err != nil {
return err
}
return nil
}
-131
View File
@@ -1,131 +0,0 @@
package users
import (
"fmt"
"strings"
)
// ResolveSourceKey maps a string that is either:
// - the configured source filesystem path (canonical Bolt / in-memory form for
// SourceScope.Name and source-type SidebarLink.SourceName), or
// - the source display name (JSON form from clients).
//
// Path is checked first so absolute paths are unambiguous. Used on write paths
// (normalize to Path) and read paths (normalize API to display Name).
func ResolveSourceKey(key string) (SourceInfo, bool) {
if sourceConfig == nil || key == "" {
return SourceInfo{}, false
}
if info, ok := sourceConfig.GetSourceByPath(key); ok {
return info, true
}
return sourceConfig.GetSourceByName(key)
}
// GetSourceNames returns all source names the user has access to (assumes backend-style scopes)
func (u *User) GetSourceNames() []string {
if sourceConfig == nil {
return []string{}
}
sources := []string{}
allSources := sourceConfig.GetAllSources()
// Preserves order of sources
for _, source := range allSources {
_, err := u.GetScopeForSourcePath(source.Path)
if err == nil {
sources = append(sources, source.Name)
}
}
return sources
}
// GetBackendScopes normalizes scopes for Bolt: SourceScope.Name is always the source filesystem path.
// Incoming rows (API) may use display name or path; legacy rows may use either.
func (u *User) GetBackendScopes() ([]SourceScope, error) {
if len(u.Scopes) == 0 {
return []SourceScope{}, nil
}
if sourceConfig == nil {
return nil, fmt.Errorf("source config not initialized")
}
newScopes := []SourceScope{}
for _, scope := range u.Scopes {
source, ok := ResolveSourceKey(scope.Name)
if !ok {
continue
}
if scope.Scope == "" {
scope.Scope = source.DefaultUserScope
}
scope.Scope = normalizeScope(scope.Scope)
newScopes = append(newScopes, SourceScope{
Name: source.Path,
Scope: scope.Scope,
})
}
return newScopes, nil
}
// GetFrontendScopes converts scopes for JSON clients: SourceScope.Name is always the source display name.
// Assumes Bolt stores paths (GetBackendScopes); unknown keys are omitted.
func (u *User) GetFrontendScopes() []SourceScope {
if sourceConfig == nil {
return []SourceScope{}
}
newScopes := []SourceScope{}
for _, scope := range u.Scopes {
source, ok := ResolveSourceKey(scope.Name)
if !ok {
continue
}
newScopes = append(newScopes, SourceScope{
Name: source.Name,
Scope: scope.Scope,
})
}
return newScopes
}
// GetBackendSidebarLinks normalizes source links for Bolt: SourceName is the filesystem path.
func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
if sourceConfig == nil {
return nil, fmt.Errorf("source config not initialized")
}
newLinks := []SidebarLink{}
for _, link := range u.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
if link.SourceName == "" {
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
}
// Validate source exists
sourceInfo, ok := sourceConfig.GetSourceByName(link.SourceName)
sourceInfo2, ok2 := sourceConfig.GetSourceByPath(link.SourceName)
if !ok && !ok2 {
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
}
if ok {
link.SourceName = sourceInfo.Path
} else {
link.SourceName = sourceInfo2.Path
}
}
newLinks = append(newLinks, link)
}
return newLinks, nil
}
// normalizeScope ensures scope starts with / and doesn't end with / (except for root)
func normalizeScope(scope string) string {
if !strings.HasPrefix(scope, "/") {
scope = "/" + scope
}
if scope != "/" && strings.HasSuffix(scope, "/") {
scope = strings.TrimSuffix(scope, "/")
}
return scope
}
-4
View File
@@ -1,4 +0,0 @@
package users
// Interface is implemented by storage
var _ Store = &Storage{}
-275
View File
@@ -1,275 +0,0 @@
package users
import (
"fmt"
"strings"
jwt "github.com/golang-jwt/jwt/v4"
)
// CurrentUserMigrationVersion is persisted for newly created accounts and after legacy migrations finish.
const CurrentUserMigrationVersion = 3
type LoginMethod string
const (
LoginMethodPassword LoginMethod = "password"
LoginMethodProxy LoginMethod = "proxy"
LoginMethodOidc LoginMethod = "oidc"
LoginMethodLdap LoginMethod = "ldap"
LoginMethodJwt LoginMethod = "jwt"
)
// WebAuthnCredentialFlags mirrors the boolean flags from authenticator data.
type WebAuthnCredentialFlags struct {
UserPresent bool `json:"userPresent"`
UserVerified bool `json:"userVerified"`
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
}
// WebAuthnCredential stores a single passkey credential in BoltDB-friendly format.
type WebAuthnCredential struct {
ID string `json:"id"`
PublicKey string `json:"publicKey"`
AttestationType string `json:"attestationType"`
AttestationFormat string `json:"attestationFormat"`
Transport []string `json:"transport"`
Flags WebAuthnCredentialFlags `json:"flags"`
AAGUID string `json:"aaguid"`
SignCount uint32 `json:"signCount"`
CloneWarning bool `json:"cloneWarning"`
Name string `json:"name"`
CreatedAt int64 `json:"createdAt"`
LastUsedAt int64 `json:"lastUsedAt"`
ClientDataJSON string `json:"clientDataJSON"`
ClientDataHash string `json:"clientDataHash"`
AuthenticatorData string `json:"authenticatorData"`
PublicKeyAlg int64 `json:"publicKeyAlg"`
AttestationObj string `json:"attestationObj"`
}
type AuthToken struct {
MinimalAuthToken
Key string `json:"key,omitempty"` // for backward compatibility
Token string `json:"token,omitempty"`
Name string `json:"name,omitempty"`
BelongsTo uint `json:"belongsTo,omitempty"`
IssuedAt int64 `json:"issuedAt,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
Permissions Permissions `json:"Permissions,omitempty"`
}
// MinimalAuthToken is used for tokens that only include JWT standard claims
type MinimalAuthToken struct {
jwt.RegisteredClaims `swaggerignore:"true"`
}
type Permissions struct {
Api bool `json:"api"` // allow api access
Admin bool `json:"admin"` // allow admin access
Modify bool `json:"modify"` // allow modifying files
Share bool `json:"share"` // allow sharing files
Realtime bool `json:"realtime"` // allow realtime updates
Delete bool `json:"delete"` // allow deleting files
Create bool `json:"create"` // allow creating or uploading files
Download bool `json:"download"` // allow downloading files
}
// SortingSettings represents the sorting settings.
type Sorting struct {
By string `json:"by"`
Asc bool `json:"asc"`
}
type Preview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // disable the hide sidebar preview for previews and editors
Image bool `json:"image"` // show thumbnail preview image for image files
Video bool `json:"video"` // show thumbnail preview image for video files
Audio bool `json:"audio"` // show thumbnail preview image for audio files
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office bool `json:"office"` // show thumbnail preview image for office files
PopUp bool `json:"popup"` // show larger popup preview when hovering over thumbnail
AutoplayMedia bool `json:"autoplayMedia"` // autoplay media files in preview
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable html5 media player and use the default media player
Folder bool `json:"folder"` // show thumbnail preview image for folder files
Models bool `json:"models"` // show live thumbnails for 3D models files
}
// User describes a persisted account. Scopes and source sidebar links store filesystem paths in Name/SourceName;
// JSON responses use display names only (see http prepForFrontend).
type User struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
ID uint `storm:"id,increment" json:"id"`
Username string `storm:"unique" json:"username"`
Scopes []SourceScope `json:"scopes"`
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
Version int `json:"version"`
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
}
type SourceScope struct {
Name string `json:"name"` // Bolt: filesystem path; JSON API: display name after prepForFrontend
Scope string `json:"scope"` // index path within that source
}
// json tags must match variable name with smaller case first letter
type NonAdminEditable struct {
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
Preview Preview `json:"preview"`
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
ShowFirstLogin bool `json:"showFirstLogin"`
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
}
type FileLoading struct {
MaxConcurrent int `json:"maxConcurrentUpload"`
UploadChunkSize int `json:"uploadChunkSizeMb"`
ClearAll bool `json:"clearAll"`
DownloadChunkSize int `json:"downloadChunkSizeMb"`
}
// SidebarLink represents a customizable link in the sidebar.
type SidebarLink struct {
Name string `json:"name"` // Display name of the link
Category string `json:"category"` // Category type: "source", "source-link", "share", "tool", "custom", etc.
Target string `json:"target"` // Target path/URL for the link (relative for source/share)
Icon string `json:"icon"` // Material icon name
SourceName string `json:"sourceName,omitempty"` // Bolt: filesystem path. JSON out: display name (after prepForFrontend).
}
func CleanUsername(s string) string {
// Remove any trailing space to avoid ending on -
s = strings.Trim(s, " ")
s = strings.Replace(s, "..", "", -1)
return s
}
// SourceNameResolver is a function type that resolves source names to source paths
// This avoids circular dependencies by allowing the settings package to provide the resolver
type SourceNameResolver func(sourceName string) (sourcePath string, err error)
// SourceInfo represents basic information about a source needed for user operations
type SourceInfo struct {
Path string
Name string
DefaultUserScope string
}
// SourceConfigProvider provides access to source configuration
type SourceConfigProvider struct {
GetSourceByPath func(path string) (SourceInfo, bool)
GetSourceByName func(name string) (SourceInfo, bool)
GetAllSources func() []SourceInfo
GetDefaultScopes func() []SourceScope
}
// Global variables set by the settings package
var (
sourceNameResolver SourceNameResolver
sourceConfig *SourceConfigProvider
)
// SetSourceNameResolver sets the global source name resolver
// This should be called once during initialization by the settings package
func SetSourceNameResolver(resolver SourceNameResolver) {
sourceNameResolver = resolver
}
// SetSourceConfig sets the global source configuration provider
// This should be called once during initialization by the settings package
func SetSourceConfig(config *SourceConfigProvider) {
sourceConfig = config
}
// SourceConfigLoaded reports whether SetSourceConfig has been called (needed for ResolveSourceKey).
func SourceConfigLoaded() bool {
return sourceConfig != nil
}
// GetScopeForSourcePath returns the scope for a given source path (backend-style)
// This method works with backend-style scopes where Name is the source path
func (u *User) GetScopeForSourcePath(sourcePath string) (string, error) {
for _, scope := range u.Scopes {
if scope.Name == sourcePath {
return scope.Scope, nil
}
}
return "", fmt.Errorf("scope not found for source %v", sourcePath)
}
// GetScopeForSourceName returns the scope for a given source name (frontend-style)
// Uses the global SourceNameResolver to convert source names to paths
func (u *User) GetScopeForSourceName(sourceName string) (string, error) {
if sourceNameResolver == nil {
return "", fmt.Errorf("source name resolver not initialized")
}
sourcePath, err := sourceNameResolver(sourceName)
if err != nil {
return "", err
}
return u.GetScopeForSourcePath(sourcePath)
}
// HasSourceByPath checks if the user has access to a given source path
func (u *User) HasSourceByPath(sourcePath string) bool {
for _, scope := range u.Scopes {
if scope.Name == sourcePath {
return true
}
}
return false
}
// GetSourcePaths returns all source paths the user has access to
func (u *User) GetSourcePaths() []string {
paths := make([]string, 0, len(u.Scopes))
for _, scope := range u.Scopes {
paths = append(paths, scope.Name)
}
return paths
}
-29
View File
@@ -1,29 +0,0 @@
package ffmpeg
import "github.com/gtsteffaniak/filebrowser/backend/common/settings"
func CanConvertImage(ext string) bool {
if !MediaEnabled() {
return false
}
val := settings.Config.Integrations.Media.Convert.ImagePreview[settings.ImagePreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
}
func CanConvertVideo(ext string) bool {
if !MediaEnabled() {
return false
}
val := settings.Config.Integrations.Media.Convert.VideoPreview[settings.VideoPreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
}
func MediaEnabled() bool {
return settings.Env.FFmpegPath != "" && settings.Env.FFprobePath != ""
}
-392
View File
@@ -1,392 +0,0 @@
package ffmpeg
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
// GetImageOrientation extracts the EXIF orientation from an image file using exiftool
func (s *FFmpegService) GetImageOrientation(imagePath string) (string, error) {
exiftoolPath := settings.Config.Integrations.Media.ExiftoolPath
if exiftoolPath == "" {
return "Horizontal (normal)", nil
}
cmd := exec.Command(exiftoolPath, "-Orientation", "-s3", imagePath)
var out bytes.Buffer
var errOut bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errOut
if err := cmd.Run(); err != nil {
return "Horizontal (normal)", nil // Default to normal orientation
}
orientation := strings.TrimSpace(out.String())
if orientation == "" {
orientation = "Horizontal (normal)" // Default if no orientation found
}
return orientation, nil
}
// GetOrientationFilter converts EXIF orientation to FFmpeg filter
func (s *FFmpegService) GetOrientationFilter(orientation string) string {
switch orientation {
// Text-based EXIF orientation values (from exiftool)
case "Rotate 90 CW", "Right-top":
return ",transpose=1" // 90° clockwise
case "Rotate 180", "Bottom-right":
return ",transpose=1,transpose=1" // 180° (two 90° rotations)
case "Rotate 270 CW", "Left-bottom":
return ",transpose=2" // 90° counter-clockwise
case "Mirror horizontal", "Top-right":
return ",hflip" // Horizontal flip
case "Mirror vertical", "Bottom-left":
return ",vflip" // Vertical flip (upside down)
case "Mirror horizontal and rotate 270 CW", "Right-bottom":
return ",transpose=0" // 90° counter-clockwise + horizontal flip
case "Mirror horizontal and rotate 90 CW", "Left-top":
return ",transpose=3" // 90° clockwise + horizontal flip
case "Horizontal (normal)", "Top-left":
return "" // No rotation needed
// Numeric EXIF orientation values (standard EXIF specification)
case "1":
return "" // Top-left (normal)
case "2":
return ",hflip" // Top-right (flip horizontal)
case "3":
return ",transpose=1,transpose=1" // Bottom-right (rotate 180)
case "4":
return ",vflip" // Bottom-left (flip vertical)
case "5":
return ",transpose=3" // Left-top (transpose: 90° CW + horizontal flip)
case "6":
return ",transpose=1" // Right-top (rotate 90 CW)
case "7":
return ",transpose=0" // Right-bottom (transverse: 90° CCW + horizontal flip)
case "8":
return ",transpose=2" // Left-bottom (rotate 270 CW / 90° CCW)
default:
return "" // Default to no rotation for unknown orientations
}
}
// GetImageDimensions extracts the dimensions of an image file using ffprobe
func (s *FFmpegService) GetImageDimensions(imagePath string) (width, height int, err error) {
// Get HEIC dimensions (fallback to individual stream dimensions for nowe re
probeCmd := exec.Command(
s.ffprobePath,
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0:s=x",
imagePath,
)
var probeOut bytes.Buffer
var probeErr bytes.Buffer
probeCmd.Stdout = &probeOut
probeCmd.Stderr = &probeErr
if err = probeCmd.Run(); err != nil {
return 0, 0, fmt.Errorf("ffprobe command failed on image file '%s': %w", imagePath, err)
}
dimensions := strings.TrimSpace(probeOut.String())
// Handle cases where ffprobe returns multiple lines or extra characters
// Take the first line and clean it up
lines := strings.Split(dimensions, "\n")
cleanDimensions := strings.TrimSpace(lines[0])
// Remove any trailing 'x' characters that might appear
cleanDimensions = strings.TrimSuffix(cleanDimensions, "x")
parts := strings.Split(cleanDimensions, "x")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid dimensions format: %s", cleanDimensions)
}
width, err = strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("invalid width: %w", err)
}
height, err = strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("invalid height: %w", err)
}
return width, height, nil
}
// ConvertHEICToJPEGDirect converts a HEIC file to JPEG using direct FFmpeg conversion (fast method)
func (s *FFmpegService) ConvertHEICToJPEGDirect(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Get EXIF orientation and create appropriate filter
orientation, err := s.GetImageOrientation(heicPath)
if err != nil {
orientation = "Horizontal (normal)"
}
orientationFilter := s.GetOrientationFilter(orientation)
// Create temporary output file
outputDir := s.cacheDir
err = os.MkdirAll(outputDir, fileutils.PermDir)
if err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
outputFile := filepath.Join(outputDir, fmt.Sprintf("heic_direct_%d.jpg", os.Getpid()))
defer os.Remove(outputFile)
// Build FFmpeg command for direct conversion
args := []string{"-i", heicPath}
// Build video filter chain with scaling and orientation
var filterChain string
if targetWidth > 0 && targetHeight > 0 {
filterChain = fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease%s", targetWidth, targetHeight, orientationFilter)
} else {
// For original size, only apply orientation if needed
if orientationFilter != "" {
filterChain = orientationFilter[1:] // Remove leading comma
}
}
// Add video filter if we have one
if filterChain != "" {
args = append(args, "-vf", filterChain)
}
// Add quality and output settings
args = append(args, "-q:v", quality, "-pix_fmt", "yuvj420p", "-y", outputFile)
cmd := exec.CommandContext(ctx, s.ffmpegPath, args...)
var cmdOut bytes.Buffer
var cmdErr bytes.Buffer
cmd.Stdout = &cmdOut
cmd.Stderr = &cmdErr
if err = cmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("direct HEIC conversion failed: %w", err)
}
// Read the converted file
jpegBytes, err := os.ReadFile(outputFile)
if err != nil {
return nil, fmt.Errorf("failed to read converted JPEG: %w", err)
}
return jpegBytes, nil
}
// ConvertHEICToJPEG converts a HEIC file to JPEG with specified dimensions and quality using proper tile extraction
func (s *FFmpegService) ConvertHEICToJPEG(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Create temporary directory for tile processing
tempDir, err := os.MkdirTemp(s.cacheDir, "heic_convert")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tempDir)
// Step 1: Get grid info using trace output
// Use ffprobe with trace level to get grid information (with time limit for speed)
gridCmd := exec.CommandContext(ctx, s.ffmpegPath, "-loglevel", "trace", "-i", heicPath, "-f", "null", "-", "-t", "0.1")
var gridOut bytes.Buffer
var gridErr bytes.Buffer
gridCmd.Stdout = &gridOut
gridCmd.Stderr = &gridErr
_ = gridCmd.Run() // Don't check error, we're just extracting info
traceOutput := gridErr.String()
// Parse grid dimensions from trace output
var gridCols, gridRows = 8, 6 // Default fallback
var val int
// Look for grid_row and grid_col in trace output
lines := strings.Split(traceOutput, "\n")
for _, line := range lines {
if strings.Contains(line, "grid_row") {
// Try to extract grid_row value
if parts := strings.Split(line, "grid_row"); len(parts) > 1 {
if matches := strings.Fields(parts[1]); len(matches) > 0 {
if val, err = strconv.Atoi(strings.Trim(matches[0], " :=")); err == nil {
gridRows = val + 1 // HEIC grid_row is 0-based
}
}
}
}
if strings.Contains(line, "grid_col") {
// Try to extract grid_col value
if parts := strings.Split(line, "grid_col"); len(parts) > 1 {
if matches := strings.Fields(parts[1]); len(matches) > 0 {
if val, err = strconv.Atoi(strings.Trim(matches[0], " :=")); err == nil {
gridCols = val + 1 // HEIC grid_col is 0-based
}
}
}
}
}
// Get EXIF orientation and create appropriate filter
orientation, err := s.GetImageOrientation(heicPath)
if err != nil {
orientation = "Horizontal (normal)"
}
orientationFilter := s.GetOrientationFilter(orientation)
// Step 2: Extract tile streams (skip stream 0 which is compatibility image)
// Check context again before extraction
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Extract using JPEG for faster I/O (PNG was unnecessarily slow and large)
// Skip the first output (stream 0) since it's the compatibility image
tilesPattern := filepath.Join(tempDir, "output_%d.jpg")
extractCmd := exec.CommandContext(ctx,
s.ffmpegPath,
"-i", heicPath,
"-map", "0", // Map all streams
"-q:v", "3", // Medium quality for intermediate tiles
"-y",
tilesPattern,
)
var extractOut bytes.Buffer
var extractErr bytes.Buffer
extractCmd.Stdout = &extractOut
extractCmd.Stderr = &extractErr
if err = extractCmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("tile extraction failed: %w", err)
}
// Step 3: Filter tiles (skip output_0.png which is compatibility image)
files, err := filepath.Glob(filepath.Join(tempDir, "output_*.jpg"))
if err != nil || len(files) == 0 {
return nil, fmt.Errorf("no tiles extracted, found %d files", len(files))
}
// Create filtered tiles, skipping output_0.jpg (compatibility image)
// and rename them to sequential tile_001.jpg, tile_002.jpg, etc.
tileIndex := 1
expectedTiles := gridCols * gridRows
for i := 1; i <= len(files); i++ { // Start from 1 to skip output_0.jpg
sourceFile := filepath.Join(tempDir, fmt.Sprintf("output_%d.jpg", i))
var info os.FileInfo
if info, err = os.Stat(sourceFile); err == nil && info.Size() > 500 {
// This is a valid tile, rename it to sequential format
targetFile := filepath.Join(tempDir, fmt.Sprintf("tile_%03d.jpg", tileIndex))
if err = os.Rename(sourceFile, targetFile); err != nil {
continue
}
tileIndex++
// Stop if we have enough tiles
if tileIndex > expectedTiles {
break
}
}
}
actualTiles := tileIndex - 1
// Check context before merge
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Step 4: Merge tiles back into final image with proper quality and rotation
outputFile := filepath.Join(tempDir, "final_output.jpg")
// Build the video filter chain
inputPattern := filepath.Join(tempDir, "tile_%03d.jpg")
// Create filter chain: tile assembly + proper scaling
var filterChain string
// For original size (targetWidth=0, targetHeight=0), don't scale
if targetWidth == 0 && targetHeight == 0 {
// This is original size - no scaling, maximum quality
// Use padding=0:margin=0 to eliminate black borders between tiles
filterChain = fmt.Sprintf("tile=%dx%d:nb_frames=%d:padding=0:margin=0%s",
gridCols, gridRows, actualTiles, orientationFilter)
} else {
// This is a resize request - apply scaling with proper aspect handling
filterChain = fmt.Sprintf("tile=%dx%d:nb_frames=%d:padding=0:margin=0%s,scale=%d:%d:force_original_aspect_ratio=decrease:eval=frame",
gridCols, gridRows, actualTiles, orientationFilter, targetWidth, targetHeight)
}
mergeCmd := exec.CommandContext(ctx,
s.ffmpegPath,
"-i", inputPattern,
"-vf", filterChain,
"-frames:v", "1",
"-q:v", quality, // Use the quality setting passed in
"-pix_fmt", "yuvj420p", // Use full-range YUV for better JPEG compatibility
"-y",
outputFile,
)
var mergeOut bytes.Buffer
var mergeErr bytes.Buffer
mergeCmd.Stdout = &mergeOut
mergeCmd.Stderr = &mergeErr
if err = mergeCmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("tile merge failed: %w", err)
}
// Step 5: Read final output
jpegBytes, err := os.ReadFile(outputFile)
if err != nil {
return nil, fmt.Errorf("failed to read merged JPEG: %w", err)
}
return jpegBytes, nil
}
// ConvertImageToJPEG converts any image format (including problematic JPEGs) to standard JPEG using FFmpeg
// This handles extended JPEG formats that Go's image/jpeg decoder doesn't support (e.g., SOF1/C1 marker)
// and can be used as a fallback for images that fail to decode with Go's standard library.
func (s *FFmpegService) ConvertImageToJPEG(ctx context.Context, imagePath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
// Use the direct conversion method which works for all image formats
return s.ConvertHEICToJPEGDirect(ctx, imagePath, targetWidth, targetHeight, quality)
}
-68
View File
@@ -1,68 +0,0 @@
package ffmpeg
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
// GetMediaDuration extracts the duration of a media file (audio or video) using ffprobe
// This method respects the concurrency limits, uses caching, and gracefully handles errors
// Cache key is based on file path + mod time for automatic cache invalidation on file changes
func (s *FFmpegService) GetMediaDuration(ctx context.Context, mediaPath string) (float64, error) {
// Get file info for cache key (path + mod time)
fileInfo, err := os.Stat(mediaPath)
if err != nil {
return 0, fmt.Errorf("failed to stat file: %w", err)
}
// Create cache key from path and modification time
cacheKey := fmt.Sprintf("%s:%d", mediaPath, fileInfo.ModTime().Unix())
// Check cache first
if duration, found := MetadataCache.Get(cacheKey); found {
return duration, nil
}
// Acquire semaphore slot for concurrency control
if err = s.Acquire(ctx); err != nil {
return 0, err
}
defer s.Release()
// Check if context is cancelled before starting
if ctx.Err() != nil {
return 0, ctx.Err()
}
cmd := exec.CommandContext(ctx,
s.ffprobePath,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
mediaPath,
)
output, err := cmd.Output()
if err != nil {
MetadataCache.Set(cacheKey, 0)
return 0, fmt.Errorf("ffprobe failed: %w", err)
}
durationStr := strings.TrimSpace(string(output))
if durationStr == "" || durationStr == "N/A" {
MetadataCache.Set(cacheKey, 0)
return 0, fmt.Errorf("could not determine media duration")
}
duration, err := strconv.ParseFloat(durationStr, 64)
if err != nil {
MetadataCache.Set(cacheKey, 0)
return 0, fmt.Errorf("invalid duration: %v", err)
}
// Cache successful result
MetadataCache.Set(cacheKey, duration)
return duration, nil
}
-104
View File
@@ -1,104 +0,0 @@
package ffmpeg
import (
"context"
"os/exec"
"path/filepath"
"runtime"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
// VideoService handles video preview operations with ffmpeg
type FFmpegService struct {
ffmpegPath string
ffprobePath string
debug bool
semaphore chan struct{}
cacheDir string
maxConcurrent int // For logging purposes
}
// NewVideoService creates a new video service instance
func NewFFmpegService(maxConcurrent int, debug bool, cacheDir string) *FFmpegService {
if settings.Env.FFmpegPath == "" || settings.Env.FFprobePath == "" {
return nil
}
return &FFmpegService{
ffmpegPath: settings.Env.FFmpegPath,
ffprobePath: settings.Env.FFprobePath,
debug: debug,
semaphore: make(chan struct{}, maxConcurrent),
maxConcurrent: maxConcurrent,
cacheDir: cacheDir,
}
}
func (s *FFmpegService) Acquire(ctx context.Context) error {
select {
case s.semaphore <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *FFmpegService) Release() {
<-s.semaphore
}
// CheckValidFFmpeg checks for a valid ffmpeg executable.
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
func CheckValidFFmpeg(path string) (string, error) {
return checkExecutable(path, "ffmpeg")
}
// CheckValidFFprobe checks for a valid ffprobe executable.
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
func CheckValidFFprobe(path string) (string, error) {
return checkExecutable(path, "ffprobe")
}
// checkExecutable is an internal helper function to find and validate an executable.
// It checks a specific path if provided, otherwise falls back to searching the system PATH.
func checkExecutable(providedPath, execName string) (string, error) {
// Add .exe extension for Windows systems
if runtime.GOOS == "windows" {
execName += ".exe"
}
var finalPath string
var err error
if providedPath != "" {
// A path was provided, so we'll use it.
finalPath = filepath.Join(providedPath, execName)
} else {
// No path was provided, so search the system's PATH for the executable.
finalPath, err = exec.LookPath(execName)
if err != nil {
// The executable was not found in the system's PATH.
return "", err
}
}
// Verify the executable is valid by running the "-version" command.
cmd := exec.Command(finalPath, "-version")
err = cmd.Run()
return finalPath, err
}
func SetFFmpegPaths() {
ffmpegMainPath, err := CheckValidFFmpeg(settings.Env.FFmpegPath)
if err != nil && settings.Env.FFmpegPath != "" {
logger.Warningf("the configured ffmpeg path does not contain a valid ffmpeg binary %s, err: %v", settings.Env.FFmpegPath, err)
}
ffprobePath, errprobe := CheckValidFFprobe(settings.Env.FFprobePath)
if errprobe != nil && settings.Env.FFprobePath != "" {
logger.Warningf("the configured ffmpeg path is not a valid ffprobe binary %s, err: %v", settings.Env.FFprobePath, err)
}
settings.Env.FFmpegPath = ffmpegMainPath
settings.Env.FFprobePath = ffprobePath
}
-186
View File
@@ -1,186 +0,0 @@
package ffmpeg
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// FFProbeOutput represents the JSON output from ffprobe
type FFProbeOutput struct {
Streams []struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Tags map[string]string `json:"tags,omitempty"`
Disposition map[string]int `json:"disposition,omitempty"`
} `json:"streams"`
}
// DetectAllSubtitles finds both embedded and external subtitle tracks
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []utils.SubtitleTrack {
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
// Check cache first
if cached, ok := MediaCache.Get(key); ok {
return cached
}
var allSubtitles []utils.SubtitleTrack
// First, get embedded subtitles
embeddedSubs := detectEmbeddedSubtitles(videoPath)
allSubtitles = append(allSubtitles, embeddedSubs...)
// Then, get external subtitle files
externalSubs := detectExternalSubtitles(videoPath, parentDir)
allSubtitles = append(allSubtitles, externalSubs...)
// Cache the complete list
MediaCache.Set(key, allSubtitles)
return allSubtitles
}
// DetectEmbeddedSubtitles detects embedded subtitle streams using ffprobe.
// This is the public API that can be called from other packages.
// Returns empty array if ffprobe is not available or fails.
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
// Check cache first
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
if cached, ok := MediaCache.Get(key); ok {
return cached
}
// Detect embedded subtitles
subtitles := detectEmbeddedSubtitles(videoPath)
// Cache the results
MediaCache.Set(key, subtitles)
return subtitles
}
// detectEmbeddedSubtitles uses ffprobe to find embedded subtitle tracks
// Always runs ffprobe - results are cached for performance
func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-select_streams", "s",
realPath)
output, err := cmd.Output()
if err != nil {
logger.Debug("ffprobe failed for file: " + realPath + ", error: " + err.Error())
return nil
}
var probeOutput FFProbeOutput
if err := json.Unmarshal(output, &probeOutput); err != nil {
logger.Debug("failed to parse ffprobe output for file: " + realPath)
return nil
}
var subtitles []utils.SubtitleTrack
for _, stream := range probeOutput.Streams {
if stream.CodecType == "subtitle" {
track := utils.SubtitleTrack{
Index: &stream.Index,
Codec: stream.CodecName,
Embedded: true, // This is an embedded subtitle
}
// Extract language and title from tags
if stream.Tags != nil {
if lang, ok := stream.Tags["language"]; ok {
track.Language = lang
}
if title, ok := stream.Tags["title"]; ok {
track.Title = title
}
}
// Generate a descriptive name
if track.Title != "" {
track.Name = track.Title
} else if track.Language != "" {
track.Name = "Embedded (" + track.Language + ")"
} else {
track.Name = "Embedded Subtitle " + strconv.Itoa(stream.Index)
}
subtitles = append(subtitles, track)
}
}
return subtitles
}
// detectExternalSubtitles finds external subtitle files in the same directory
func detectExternalSubtitles(videoPath string, parentDir string) []utils.SubtitleTrack {
var subtitles []utils.SubtitleTrack
// Get the base name of the video (without extension)
videoBaseName := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
// Common subtitle extensions
subtitleExts := []string{".srt", ".vtt", ".lrc", ".sbv", ".ass", ".ssa", ".sub", ".smi"}
// Look for subtitle files with matching base name
for _, ext := range subtitleExts {
subtitlePath := filepath.Join(parentDir, videoBaseName+ext)
if _, err := os.Stat(subtitlePath); err == nil {
// File exists
track := utils.SubtitleTrack{
Name: filepath.Base(subtitlePath),
Embedded: false, // This is an external file
}
// Try to infer language from filename patterns like "video.en.srt"
parts := strings.Split(videoBaseName, ".")
if len(parts) > 1 {
// Check if the last part before extension looks like a language code
lastPart := parts[len(parts)-1]
if len(lastPart) == 2 || len(lastPart) == 3 {
track.Language = lastPart
}
}
subtitles = append(subtitles, track)
}
}
return subtitles
}
// ExtractSubtitleContent extracts subtitle content without service management
func ExtractSubtitleContent(videoPath string, streamIndex int) (string, error) {
cmd := exec.Command("ffmpeg",
"-i", videoPath,
"-map", fmt.Sprintf("0:%d", streamIndex),
"-c:s", "webvtt",
"-f", "webvtt",
"-") // output to stdout
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("subtitle extraction failed: %v", err)
}
return string(output), nil
}
// LoadSubtitleFile loads a subtitle file and returns its raw content
func LoadSubtitleFile(subtitlePath string) (string, error) {
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
-114
View File
@@ -1,114 +0,0 @@
package ffmpeg
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"github.com/gtsteffaniak/go-logger/logger"
)
// GenerateVideoPreviewStreaming generates a video preview and streams it directly to a writer
// This is more memory efficient for large previews as it doesn't load the entire file into memory
func (s *FFmpegService) GenerateVideoPreviewStreaming(ctx context.Context, videoPath string, percentageSeek int, writer io.Writer) error {
if err := s.Acquire(ctx); err != nil {
return err
}
defer s.Release()
// Check if context is cancelled before starting
if ctx.Err() != nil {
return ctx.Err()
}
// Validate percentage parameter
if percentageSeek < 0 || percentageSeek > 100 {
percentageSeek = 10 // Default to 10% if invalid
}
// Step 1: Get video duration from the container format
probeCmd := exec.CommandContext(ctx,
s.ffprobePath,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
videoPath,
)
var probeOut bytes.Buffer
probeCmd.Stdout = &probeOut
if s.debug {
probeCmd.Stderr = os.Stderr
}
if err := probeCmd.Run(); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
// Capture stderr output for better debugging
stderrOutput := ""
if probeCmd.Stderr != nil {
if stderrBuf, ok := probeCmd.Stderr.(*bytes.Buffer); ok {
stderrOutput = stderrBuf.String()
}
}
logger.Errorf("ffprobe command failed on file '%s': %v (stderr: %s)", videoPath, err, stderrOutput)
return fmt.Errorf("ffprobe failed: %w", err)
}
durationStr := strings.TrimSpace(probeOut.String())
if durationStr == "" || durationStr == "N/A" {
logger.Errorf("could not determine video duration for file '%v' using duration info '%v'", videoPath, durationStr)
return fmt.Errorf("could not determine video duration")
}
durationFloat, err := strconv.ParseFloat(durationStr, 64)
if err != nil {
return fmt.Errorf("invalid duration: %v", err)
}
if durationFloat <= 0 {
return fmt.Errorf("video duration must be positive")
}
// Step 2: Calculate seek time with higher precision
seekTime := durationFloat * float64(percentageSeek) / 100.0
seekTimeStr := strconv.FormatFloat(seekTime, 'f', 3, 64) // 3 decimal places precision
// Step 3: Extract frame and stream directly to writer
cmd := exec.CommandContext(ctx,
s.ffmpegPath,
"-ss", seekTimeStr, // Use precise seek time
"-i", videoPath,
"-frames:v", "1",
"-q:v", "10", // Good quality/size balance
"-f", "image2", // Explicitly specify image format
"-vcodec", "mjpeg", // Use MJPEG codec for better compression
"-", // Output to stdout
)
cmd.Stdout = writer
var stderrBuf bytes.Buffer
if s.debug {
cmd.Stderr = os.Stderr
} else {
// Capture stderr for error logging
cmd.Stderr = &stderrBuf
}
err = cmd.Run()
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
stderrOutput := stderrBuf.String()
logger.Errorf("ffmpeg command failed on file '%s' (seek: %s): %v (stderr: %s)",
videoPath, seekTimeStr, err, stderrOutput)
return fmt.Errorf("ffmpeg command failed on file '%v' : %w", videoPath, err)
}
return nil
}
+133 -123
View File
@@ -1,135 +1,141 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.25.0
go 1.27.0
require (
github.com/alecthomas/kong v1.16.1
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.18.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.15
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.17.3
github.com/evanoberholster/imagemeta v1.0.0
github.com/gen2brain/go-fitz v1.28.2
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-playground/validator/v10 v10.30.4
github.com/go-webauthn/webauthn v0.18.0
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-ffmpeg v0.5.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.21
github.com/mattn/go-sqlite3 v1.14.44
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.50
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.51.0
golang.org/x/image v0.40.0
golang.org/x/mod v0.36.0
golang.org/x/net v0.54.0
golang.org/x/crypto v0.56.0
golang.org/x/image v0.45.0
golang.org/x/mod v0.40.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.44.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.45.0
golang.org/x/tools v0.49.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1
modernc.org/sqlite v1.58.0
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
charm.land/lipgloss/v2 v2.0.3 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
codeberg.org/chavacava/garif v0.2.1 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/golib v0.6.0 // indirect
github.com/4meepo/tagalign v1.4.3 // indirect
github.com/Abirdcfly/dupword v0.1.7 // indirect
dev.gaijin.team/go/exhaustruct/v5 v5.0.3 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
github.com/4meepo/tagalign v1.4.4 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
github.com/Antonboom/errname v1.1.2 // indirect
github.com/Antonboom/nilnil v1.1.2 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.65.1 // indirect
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.4.0 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.3 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bits-and-blooms/bitset v1.25.0 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.8.0 // indirect
github.com/bombsimon/wsl/v5 v5.9.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/butuzov/mirror v1.3.3 // indirect
github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260903151058-ae99b731b8c5 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/daixiang0/gci v0.14.0 // indirect
github.com/dave/dst v0.27.4 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/ebitengine/purego v0.11.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/firefart/nonamedreturns v1.0.8 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghostiam/protogetter v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-critic/go-critic v0.15.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-openapi/jsonpointer v1.0.1 // indirect
github.com/go-openapi/jsonreference v1.0.2 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
github.com/go-openapi/swag/conv v0.29.1 // indirect
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
github.com/go-openapi/swag/loading v0.29.1 // indirect
github.com/go-openapi/swag/pools v0.29.1 // indirect
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -140,29 +146,30 @@ require (
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.5 // indirect
github.com/go-webauthn/x v0.3.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/godoc-lint/godoc-lint v0.11.4 // indirect
github.com/gofrs/flock v0.13.1 // indirect
github.com/gohugoio/hashstructure v1.1.0 // indirect
github.com/gohugoio/hugo v0.165.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.12.2 // indirect
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
github.com/golangci/golangci-lint/v2 v2.13.2 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
@@ -171,109 +178,104 @@ require (
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.10.0 // indirect
github.com/jgautheron/goconst v1.11.0 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.7.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kisielk/errcheck v1.20.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.8.0 // indirect
github.com/ldez/gomoddirectives v0.9.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.7.2 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.6.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.29 // indirect
github.com/mgechev/revive v1.16.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/raeperd/recvcheck v0.3.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.26.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/securego/gosec/v2 v2.29.0 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
github.com/timonwong/loggercheck v0.12.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.4.2 // indirect
github.com/uudashr/iface v1.5.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
@@ -281,25 +283,25 @@ require (
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go.augendre.info/fatcontext v0.10.0 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20260824195058-e88cd73687aa // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.72.3 // indirect
honnef.co/go/tools v0.8.1 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
modernc.org/memory v1.12.1 // indirect
mvdan.cc/gofumpt v0.11.0 // indirect
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
tool (
@@ -307,3 +309,11 @@ tool (
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
github.com/swaggo/swag/cmd/swag
)
exclude (
github.com/alecthomas/go-check-sumtype v0.5.0
github.com/gobwas/glob v1.0.0
github.com/gtsteffaniak/go-ffmpeg v1.0.0
github.com/gtsteffaniak/go-ffmpeg v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.2.0
)
+326 -717
View File
File diff suppressed because it is too large. Load diff
-454
View File
@@ -1,454 +0,0 @@
package http
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
libError "errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// first checks for cookie
// then checks for header Authorization as Bearer token
// then checks for query parameter
func extractToken(r *http.Request) (string, error) {
hasToken := false
tokenObj, err := r.Cookie("filebrowser_quantum_jwt")
if err == nil {
hasToken = true
token := tokenObj.Value
// Checks if the token isn't empty and if it contains two dots.
// The former prevents incompatibility with URLs that previously
// used basic auth.
if token != "" && strings.Count(token, ".") == 2 {
return token, nil
}
}
auth := r.URL.Query().Get("auth")
if auth != "" {
hasToken = true
if strings.Count(auth, ".") == 2 {
return auth, nil
}
}
// Check for Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader != "" {
hasToken = true
// Split the header to get "Bearer {token}"
parts := strings.Split(authHeader, " ")
if len(parts) > 1 {
// some clients don't respect RFC regarding cases
switch strings.ToLower(parts[0]) {
case "bearer":
return parts[1], nil
case "basic":
// compatibility for basic auth
// user ignored, password is token
_, token, ok := r.BasicAuth()
if ok {
return token, nil
}
}
}
}
if hasToken {
return "", fmt.Errorf("invalid token provided")
}
return "", request.ErrNoTokenInRequest
}
func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*users.User, error) {
var err error
// Retrieve the user from the store and store it in the context
data.user, err = store.Users.Get(proxyUser)
if err != nil {
if err.Error() != "the resource does not exist" {
return nil, err
}
// Auto-create user on first proxy authentication
user := users.User{
LoginMethod: users.LoginMethodProxy,
Username: proxyUser,
}
settings.ApplyUserDefaults(&user)
if user.Username == config.Auth.AdminUsername {
user.Permissions.Admin = true
}
err = storage.CreateUser(user, user.Permissions)
if err != nil {
return nil, err
}
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return nil, err
}
}
if data.user.LoginMethod != users.LoginMethodProxy {
return nil, errors.ErrWrongLoginMethod
}
if data.user.Username == config.Auth.AdminUsername && !data.user.Permissions.Admin {
data.user.Permissions.Admin = true
err = store.Users.Update(data.user, true, "Permissions")
if err != nil {
return nil, err
}
}
return data.user, nil
}
// setupJwtUser retrieves or creates a user based on external JWT token claims
func setupJwtUser(r *http.Request, data *requestContext, username string, claims map[string]interface{}) (*users.User, error) {
var err error
// Retrieve the user from the store
data.user, err = store.Users.Get(username)
if err != nil {
if err.Error() != "the resource does not exist" {
return nil, err
}
// Auto-create user on first JWT authentication
user := users.User{
LoginMethod: users.LoginMethodJwt,
Username: username,
}
settings.ApplyUserDefaults(&user)
// Check if user should be admin based on groups
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
for _, group := range groups {
if group == config.Auth.Methods.JwtAuth.AdminGroup {
user.Permissions.Admin = true
break
}
}
}
// Also check if username matches admin username
if user.Username == config.Auth.AdminUsername {
user.Permissions.Admin = true
}
err = storage.CreateUser(user, user.Permissions)
if err != nil {
return nil, err
}
data.user, err = store.Users.Get(username)
if err != nil {
return nil, err
}
}
// Verify login method matches
if data.user.LoginMethod != users.LoginMethodJwt {
return nil, errors.ErrWrongLoginMethod
}
// Check if user is in allowed groups (if UserGroups is configured)
if len(config.Auth.Methods.JwtAuth.UserGroups) > 0 {
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
allowed := false
for _, userGroup := range groups {
for _, allowedGroup := range config.Auth.Methods.JwtAuth.UserGroups {
if userGroup == allowedGroup {
allowed = true
break
}
}
if allowed {
break
}
}
if !allowed {
return nil, fmt.Errorf("user is not in allowed groups")
}
}
// Update admin status if needed
shouldBeAdmin := false
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
for _, group := range groups {
if group == config.Auth.Methods.JwtAuth.AdminGroup {
shouldBeAdmin = true
break
}
}
}
if data.user.Username == config.Auth.AdminUsername {
shouldBeAdmin = true
}
if shouldBeAdmin != data.user.Permissions.Admin {
data.user.Permissions.Admin = shouldBeAdmin
err = store.Users.Update(data.user, true, "Permissions")
if err != nil {
return nil, err
}
}
return data.user, nil
}
// loginHandler handles user authentication via password.
// @Summary User login
// @Description Authenticate a user with a username and password. The password must be URL-encoded and sent in the X-Password header to support special characters (e.g., ^, %, £, €, etc.).
// @Tags Auth
// @Accept json
// @Produce json
// @Param username query string true "Username"
// @Param recaptcha query string false "ReCaptcha response token (if enabled)"
// @Param X-Password header string true "URL-encoded password"
// @Param X-Secret header string false "TOTP code (if 2FA is enabled)"
// @Success 200 {string} string "JWT token for authentication"
// @Failure 401 {object} map[string]string "Unauthorized - authentication failed"
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
// @Failure 429 {object} map[string]string "Too many requests - rate limited or temporarily locked out after failed attempts"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/login [post]
func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
passwordUser := d.user.LoginMethod == users.LoginMethodPassword
enforcedOtp := config.Auth.Methods.PasswordAuth.EnforcedOtp
missingOtp := d.user.TOTPSecret == ""
if passwordUser && enforcedOtp && missingOtp {
return http.StatusForbidden, errors.ErrNoTotpConfigured
}
if d.user.HasPasskeyMFA() && d.user.TOTPSecret == "" {
return http.StatusForbidden, errors.ErrPasskeyMFARequired
}
return printToken(w, r, d.user)
}
// logoutHandler handles user logout
// @Summary User Logout
// @Description Returns a logout URL for the frontend to redirect to.
// @Tags Auth
// @Produce json
// @Param auth query string false "JWT token"
// @Success 200 {object} map[string]string "{"logoutUrl": "http://..."}"
// @Router /api/auth/logout [post]
func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if err := auth.RevokeApiToken(store.Access, d.token); err != nil {
logger.Errorf("Failed to revoke token on logout: %v", err)
}
// Clear the authentication cookie by setting it to expire in the past
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: "",
Domain: strings.Split(host, ":")[0],
Path: "/",
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0), // Expire immediately
MaxAge: -1, // Delete cookie
}
http.SetCookie(w, cookie)
logoutUrl := fmt.Sprintf("%vlogin", config.Server.BaseURL) // Default fallback
if d.user != nil && d.user.LoginMethod == users.LoginMethodProxy {
proxyRedirectUrl := config.Auth.Methods.ProxyAuth.LogoutRedirectUrl
if proxyRedirectUrl != "" {
logoutUrl = proxyRedirectUrl
}
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodOidc {
oidcRedirectUrl := config.Auth.Methods.OidcAuth.LogoutRedirectUrl
if oidcRedirectUrl != "" {
logoutUrl = oidcRedirectUrl
}
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodLdap {
ldapRedirectUrl := config.Auth.Methods.LdapAuth.LogoutRedirectUrl
if ldapRedirectUrl != "" {
logoutUrl = ldapRedirectUrl
}
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodJwt {
jwtRedirectUrl := config.Auth.Methods.JwtAuth.LogoutRedirectUrl
if jwtRedirectUrl != "" {
logoutUrl = jwtRedirectUrl
}
}
if logoutUrl == "" {
logger.Debug("no logout url found, using default")
logoutUrl = fmt.Sprintf("%vlogin", config.Server.BaseURL)
}
response := map[string]string{
"logoutUrl": logoutUrl,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(response)
if err != nil {
return http.StatusInternalServerError, err
}
return http.StatusOK, nil
}
// signupHandler registers a new user account.
// @Summary User signup
// @Description Register a new user account with a username and password.
// @Tags Auth
// @Accept json
// @Produce json
// @Success 201 {string} string "User created successfully"
// @Failure 400 {object} map[string]string "Bad request - invalid input"
// @Failure 405 {object} map[string]string "Method not allowed - signup is disabled"
// @Failure 409 {object} map[string]string "Conflict - user already exists"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/signup [post]
func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !config.Auth.Methods.PasswordAuth.Signup {
return http.StatusMethodNotAllowed, fmt.Errorf("signup is disabled")
}
// Get credentials from query parameters
username := r.URL.Query().Get("username")
password := r.URL.Query().Get("password")
// Validate that we have both username and password
if username == "" || password == "" {
return http.StatusBadRequest, fmt.Errorf("username and password are required")
}
user := users.User{
Username: username,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
LoginMethod: users.LoginMethodPassword,
}
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(config.UserDefaults.Account.Permissions))
if err != nil {
logger.Debug(err.Error())
// Return the actual error message instead of a generic one
return http.StatusBadRequest, err
}
return 201, nil
}
// renewHandler refreshes the authentication token for a logged-in user.
// @Summary Renew authentication token
// @Description Refresh the authentication token for a logged-in user.
// @Tags Auth
// @Accept json
// @Produce json
// @Success 200 {string} string "New JWT token generated"
// @Failure 401 {object} map[string]string "Unauthorized - invalid token"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/renew [post]
func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// check if x-auth header is present and token is
return printToken(w, r, d.user)
}
func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int, error) {
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
tokenString, _, err := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
}
return 401, errors.ErrUnauthorized
}
// Add 30 minutes buffer so expired token doesn't get automatically deleted by the browser
// This allows backend to identify expired sessions and provide better user feedback
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
setSessionCookie(w, r, tokenString, expiresTime)
// Still return token in body for backward compatibility and state management
w.Header().Set("Content-Type", "text/plain")
if _, err := w.Write([]byte(tokenString)); err != nil {
return 401, errors.ErrUnauthorized
}
return 0, nil
}
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
if l.PasswordHash == "" {
return 200, nil
}
tokenParam := r.URL.Query().Get("token")
if tokenParam != "" {
// Verify the token signature if it's in the new signed format
if strings.Contains(tokenParam, ".") {
parts := strings.Split(tokenParam, ".")
if len(parts) == 2 {
payload := parts[0]
signature := parts[1]
// Verify HMAC signature
mac := hmac.New(sha256.New, []byte(config.Auth.Key))
mac.Write([]byte(payload))
expectedSignature := base64.URLEncoding.EncodeToString(mac.Sum(nil))
// Use constant-time comparison to prevent timing attacks
if hmac.Equal([]byte(signature), []byte(expectedSignature)) {
// Token signature is valid, now check if it matches stored token
if tokenParam == l.Token {
return 200, nil
}
}
}
} else {
// Legacy token format (plain base64) - direct comparison
if tokenParam == l.Token {
return 200, nil
}
}
}
password := r.Header.Get("X-SHARE-PASSWORD")
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return http.StatusUnauthorized, nil
}
return 401, err
}
return 200, nil
}
// setSessionCookie - sets the authentication token as an HTTP cookie
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: token,
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
Path: "/",
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
Expires: expiresTime,
}
http.SetCookie(w, cookie)
}
Loaded 100 of 837 files, more files were not shown because too many files have changed in this diff. Show more