Compare commits

..
Author SHA1 Message Date
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
687dc01293 Update en.json (#2456)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 14:43:31 -05:00
bfde65a41e Update nl.json (#2448)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 14:43:18 -05:00
1802e12811 updated to fix critical bug (#2424) (#2427)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
2026-05-14 10:47:26 -05:00
0f215333ee Beta/v1.4.0 (#2421)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
2026-05-13 14:56:35 -05:00
Graham Steffaniak 6d81d1973c Beta/v1.3.10 (#2395) 2026-05-08 14:06:45 -05:00
Graham Steffaniak 869b640a99 Update auth.go (#2381) 2026-05-04 13:32:54 -05:00
Stephan Paternotte 3b8d179774 Update nl.json (#2383) 2026-05-04 12:26:28 -05:00
Graham Steffaniak 569314cdf4 Beta/v1.3.9 (#2377) 2026-05-01 16:11:48 -05:00
Graham Steffaniak 668efec4d7 added new screenshots (#2371) 2026-04-30 15:50:36 -05:00
Graham Steffaniak a5c81c8e2e Beta/v1.3.8 (#2363) 2026-04-30 11:55:14 -05:00
Graham Steffaniak 88aa337696 Beta/v1.3.7 (#2350) 2026-04-27 16:32:13 -05:00
9b48ce414e Beta/v1.3.6 (#2324)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-20 17:00:10 -05:00
0723edf660 Beta/v1.3.5 (#2316)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-18 18:24:10 -05:00
Gaurav Kalal d2165ec0c3 Fix: Prevent portrait mobile overflow for large placeholder loader (#2272) 2026-04-13 09:59:10 -05:00
78c554719f Beta/v1.3.4 (#2273)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: SIDDHU SINGH <siddhu3116@gmail.com>
Co-authored-by: Taufik Hidayat <taufikdev88@gmail.com>
2026-04-11 19:48:26 -05:00
Taufik Hidayat aa8f9e2923 Add resolve nested groupAdmin to oidc auth method (#2260) 2026-04-10 15:39:49 -05:00
Graham SteffaniakandKevin B. 3bfd9d9be0 Beta/v1.3.3 (#2234)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-04-03 13:40:25 -05:00
carloda ce5269e8e9 Fix and improve Italian translation (it.json) (#2198) 2026-03-30 15:57:45 -05:00
Kevin B. 6d21c7918e update spanish translation (#2200) 2026-03-30 15:57:25 -05:00
Graham Steffaniak 2693026f6c Revert "Update translation script" (#2182) 2026-03-23 19:22:18 -05:00
Kevin B. eb4dfa5710 Update translation script (#2178) 2026-03-23 19:21:25 -05:00
Stephan Paternotte 363619bcb4 Update nl.json - minor corrections (#2172) 2026-03-22 16:27:05 -05:00
ab9790427f Beta/v1.3.2 (#2169)
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>
2026-03-21 16:22:31 -05:00
Kevin B. eabdfd9aa2 fix wrong username in share settings (#2148) 2026-03-18 15:14:01 -05:00
Graham Steffaniak af08800667 Username enumeration fix (#2144) 2026-03-17 18:06:26 -05:00
Graham Steffaniak 319b2d06e6 upgrade ffmpeg to version 8.1 (#2139) 2026-03-17 12:57:34 -05:00
Graham Steffaniak 2d2b3ade62 deprecate maxArchiveSizeGb (#2137) 2026-03-17 11:22:46 -05:00
janakoram 513946bdbf changed folder downloads to stream archive directly (#2125) 2026-03-16 17:39:38 -05:00
Peter Dave Hello 86ed0891e0 Improve zh-TW Traditional Chinese locale (#2115) 2026-03-10 11:03:16 -05:00
Graham Steffaniak d8bf9422c2 Update README.md (#2116) 2026-03-10 11:03:07 -05:00
Stephan Paternotte a7169e8dad Update nl.json for Beta/v1.3.1 (#2111) 2026-03-10 09:10:37 -05:00
Mickael 72f9fcbd64 fix (docs): incorrect Filestash feature listing (#2109) 2026-03-09 18:59:18 -05:00
d192ede484 Beta/v1.3.1 (#2104)
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>
2026-03-07 17:24:57 -05:00
dependabot[bot] da0c8bd0f1 Bump the npm_and_yarn group across 1 directory with 2 updates (#2046)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 09:22:53 -06:00
Graham Steffaniak 5108330405 an new announcement (#2049) 2026-02-28 07:56:40 -06:00
1c7e3c2ad2 Beta/v1.3.0 (#2045)
Signed-off-by: Joel Godfrey <m.joelgodfrey@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
Co-authored-by: Joel Godfrey <82655889+BaccanoMob@users.noreply.github.com>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-02-27 18:45:59 -06:00
852 changed files with 127689 additions and 43572 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
{
"name": "filebrowser",
"image": "mcr.microsoft.com/devcontainers/go:1.25-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"containerEnv": {
"CGO_ENABLED": "1",
"FILEBROWSER_DEVMODE": "true"
},
"forwardPorts": [80],
"portsAttributes": {
"80": {
"label": "filebrowser app",
"onAutoForward": "notify"
}
},
"postCreateCommand": "make setup",
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
]
}
}
}
+8 -1
View File
@@ -1,8 +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
+45 -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
@@ -30,3 +33,44 @@ jobs:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_beta_release:
needs: [test_playwright]
if: startsWith(github.event.head_commit.message, 'Beta/v')
name: Create Beta Release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
fetch-depth: 0
- name: Extract version from commit message
id: extract_version
shell: bash
run: |
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
exit 1
fi
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
- name: Tag release commit on main
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
TAG="${{ steps.extract_version.outputs.tag_name }}"
git tag -f "$TAG" "${{ github.sha }}"
git push origin "refs/tags/$TAG" --force
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_version.outputs.tag_name }}
prerelease: false
make_latest: false
draft: false
generate_release_notes: true
overwrite_existing: true
name: ${{ steps.extract_version.outputs.tag_name }}
+14 -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,7 +28,7 @@ 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
@@ -40,10 +40,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 +61,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:
@@ -80,6 +80,12 @@ jobs:
dockerfile: ./_docker/Dockerfile.playwright-no-config
- name: settings
dockerfile: ./_docker/Dockerfile.playwright-settings
- name: screenshots
dockerfile: ./_docker/Dockerfile.playwright-screenshots
- name: previews
dockerfile: ./_docker/Dockerfile.playwright-previews
- name: jwt
dockerfile: ./_docker/Dockerfile.playwright-jwt
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -97,8 +103,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
@@ -118,7 +124,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
@@ -11,7 +11,7 @@ on:
jobs:
promote-beta-to-stable:
name: Promote Beta to Stable
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Check out repository
+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
+26 -13
View File
@@ -7,28 +7,25 @@ on:
jobs:
test-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'
- working-directory: backend
run: go test -race -v ./...
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,23 +34,39 @@ 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
- 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
- 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
- working-directory: frontend
run: npm i && npm run i18n:check
run: npm i && npm run i18n:check
check-docs:
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
check-icons:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- 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 }}
+2 -2
View File
@@ -11,7 +11,7 @@ permissions:
jobs:
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -36,7 +36,7 @@ jobs:
create_release_tag:
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
+1 -1
View File
@@ -11,7 +11,7 @@ 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
+2 -2
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
@@ -27,7 +27,7 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/gtsteffaniak/playwright-base
tags: latest
tags: chromium
- name: Build and push playwright-base
uses: docker/build-push-action@v6
with:
+10 -2
View File
@@ -1,4 +1,8 @@
*.db
*.db.old
# Playwright settings migration fixture (BoltDB → SQLite tests + Docker settings image)
!_docker/src/settings/backend/database.db.old
*.sqlite**
*.bak
*.log
*.mjs
@@ -18,15 +22,19 @@ frontend/tsconfig.tsbuildinfo
/frontend/test-results
/frontend/playwright-files
/frontend/screenshots
/frontend/generated
frontend/.venv-icons
frontend/.tmp-icons-*
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/frontend/sharePrepStorage.json
/backend/vendor
/backend/*.cov
/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
+499 -3
View File
@@ -2,19 +2,515 @@
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.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**:
- [Moderate] Add Rate Limiting on Authentication Endpoint Enables Brute Force Attacks (GHSA-r4v7-6wcg-ghj5)
- [Critical] Path traversal in public share PATCH allows file ops outside shared directory -- thanks @Revanth011 and @fg0x0 (GHSA-qqqm-5547-774x)
**New Features**:
- read-only source configuration via `source.config.readOnly: true` (#2438)
**Notes**:
- auth rate limiting can be disabled via `auth.disableRateLimit`
- updated share hash middleware (#2443)
- updated source info popup to include private and readOnly properties
**BugFixes**:
- Logout from share page now redirects to the share instead of `/Login` again. (#2245)
- `This location cannot be reached` error when navigating with FileTree in shares. (#2245)
- Fix FileTree rename and move actions in previews. (#2245)
- Delete prompt not showing date and thumbnails in some previews. (#2245)
- fix path slash issue on windows (#2451) (#2433) (#2419)
- Always force url rewrite for onlyoffice internal URL. Fixes Error saving with OnlyOffice (#2450)
- Overriding a Deny with an Allow not working (#2405)
## v1.4.1
**Security**:
- Fix critical: unauthenticated user can view source info
## v1.4.0
**New Features**:
- Add Passkey (WebAuthn) support for passwordless authentication (#2287) (#2379) -- thanks @juansoler
- Lyrics support for audio files (#2360):
- Updated UI for audio previews!
- The lyrics and the player gets syncronized in UI with the audio player.
- Supports embedded lyrics (ID3v2 USLT, flac, ogg, clyr) and sidecar `.lrc` files with the same name as the audio file.
- The `Q` shortcut in audio files will toggle the new panel visibility.
- `E` shortcut to change between tabs of the panel if it's open.
- Api log filtering support via `apiFilter` option for logging.
- regex filter that excludes matching full api paths from being logged. (eg. `/user\?id\=self`) Defaults to `^/health|^/favicon.ico|^/static|^/public/static`
- Add config option to disable /health endpoint logging (#2291)
- More shortcuts (#2300)
- `CTRL+F1` switch to normal view.
- `CTRL+F2` switch to gallery view.
- `CTRL+F3` switch to list view.
- `Double click` on empty space to select all items.
- Option to hide certain files in UI by extension (#2403) (#2277)
- Additional search features
- wildcard search similar to regex, but limited to sqlite GLOB syntax (#769)
- advanced search tool that shows all results in a listing thats sortable and actionable like a normal listing (#1051)
- advanced search supports multiple custom scopes per search
- multiple search terms with AND or OR logic support
**Notes**:
- Enhanced indexing scheduler which doesn't wake the disk as often.
- New API route `media/lyrics` used to fetch and parse lyrics (embedded or from `.lrc` sidecar) (#2360)
- Swiping down gesture in fullscreen videos exit fullscreen instead of close preview.
- Improved styles for path selection and tables
- Improved style of drag and drop into listing view (#2407)
- Edit Sidebar links has new "show tools in sidebar" toggle and all users have this enabled by default. can be disabled via for new users `userDefaults.sidebar.showTools: false`
- Update user defaults ordering (#1140)
- Save view modes and sizes into local storage instead of db (#2301)
**BugFixes**:
- Blue overlay when using gestures in video files on mobile (#2360)
- Playback queue wasn't updating when changing of folder (#2360)
- Added missing `UserDefaults.listing.showCopyPath` option to config file (#2364)
- Delete prompt thumbnail didn't worked when using it with the `delete` shortcut. (#2407)
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
- Drag and drop style state was stuck until page reload (#2407)
- Navigate close settings shows "something went wrong" (#2047)
## v1.3.10
**Notes**:
- When indexing is disabled for a source, the usage will always reported as partition size.
- removed deprecated `source.config.disableIndexing`, see [rules](https://filebrowserquantum.com/en/docs/advanced/source-configuration/conditional-rules/#disable-indexing)
**BugFixes**:
- Disable index option not working (#2385)
## v1.3.9
**Security**:
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
**BugFixes**:
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
## v1.3.8
**BugFixes**:
- Quick download icon style after icon change.
- Missing error popup for resource creatoin actions (upload/create)
- EnforcedOtp login failure until restart (#2330)
- Thumbnails for Folders only display sporadically (#2353)
- Unwanted user scope change for users with non-default scopes (#2347)
- Fix sidebar source info totals (#2321) (#2322) (#982)
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
## v1.3.7
**Notes**:
- creating/deleting password-based user requires reauthentication (#2112)
**BugFixes**:
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
- No Loginfields shown if password authentification is set to false (#2331)
## v1.3.6
**BugFixes**:
- file watcher not working for files and not respecting line count (#2314)
- "source" type link shows up multiple times when adding sidebar links if user has multiple sources.
- JWT ES256 results in error (#2188)
- single file shares not loading editor text content (#2122)
## v1.3.5
**Notes**:
- any password change or admin user property change for a password user requires reauthentication (#2112)
**BugFixes**:
- PWA icon fixes (#2292)
- deny-rule'd folders visible in directory listings (regression from v1.2.4-stable) (#2295)
- OTP and password requirement fixes (#2112) (#2263)
- CLI not picking up config properly
- Storage usage numbers on first load adjusted.
- Some items in userDefaults are not setting defaults for new users properly #2278
- Fix copy to clipboard and simplify code (#2281) (#2274)
- Fix search shortcut `/` and pdf previews (#2307)
- Special permissions bit not set when declared in createDirectoryPermission (#2283)
- Execute permission altered after editing sh script with FB Quantum (#2309)
- Source is red but working (#2289)
## v1.3.4
**New Features**:
- Epub placementg url anchors to bookmark a specific location on the doc.
- Add Requirement for Current Password When Changing Account Password (#2112)
- Copy file path to clipborad through right click (#2204)
**Notes**:
- user scope editing has path picker and filesystem validation
**BugFixes**:
- Swiping media files doesn't respect Playback Queue (#2243)
- Audio metadata and album thumbnails doesn't work (#2236)
- Search spinner wouldn't show up sometimes while searching
- The default landing page source should always be the first source in the sidebar, that wasn't always respected.
## v1.3.3
**New Features**:
- Copy/paste files and folders (`CTRL+C`/`CTRL+V`) from other apps (win explorer, thunar, finder, etc) to upload them directly (#2197)
- Caption font size can be adjusted in video settings
- Ability to adjust the startup check method for sql database via `server.startupIntegrityCheck` (#2221)
- Status bar for editor and markdown viewer (#2226)
**Notes**:
- `.fbx` 3d model files added to supported previews.
- removed upx compression on docker image (#2193)
- videos double tap to fast-forward and rewind added.
- Adjust Image album swipe behavior (#2068)
- swipe animation for next/previous
- swipe down on image to close and go to parent folder.
- supports videos
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
- The Icons in the UI were updated! (#2203)
- More supported icons in the Icon Picker tool.
- More file types have new icons across all the listings (such as `.md`, `.apk`, etc).
- `.fbx` 3d model files added to supported previews.
- removed upx compression on docker image (#2193)
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
- deprecated `source.config.CreateUserDir`, now its always `true`. If a user directory doesn't exist it will get created empty.
- `CTRL`+`Mouse Wheel` shortcut to change listing size. Also for change font size in editor (#2227)
**BugFixes**:
- Archives don't preserve file metadata (#2063)
- Chunked upload setting fails: "0" does not disable chunking as expected (#2202)
- Incorrect Chinese labels for Upload controls in Quantum UI (#2191)
- Chunked download stops after first chunk, add message explaining (#2074)
- Esc to Cancel and Enter to confirm popup (#2079)
- Language-tagged subtitle files not detected for videos (#2199)
- Unable to use proxy auth since 1.3.0-beta (#2173)
- Updating setting on a LDAP user turns the "Login Method" to password. Thus preventing further logins. (#2179)
## v1.3.2-beta
**Security**:
- Patched Username Enumeration via Authentication Timing Side-Channel GHSA-7789-65hx-f26w
**New Features**:
- Option in settings `userDefaults.preferEditorForMarkdown` to prefer editor first for Markdown files (#2136)
- Copy to clipboard button for code blocks in Markdown Viewer (#2160)
- Add "Last modified" filter in search dialog (#2157)
**Notes**:
- docs preview for text and pdf has a 2 second timeout. If it hangs for whatever reason, the maximum time would be 2 seconds. (#2105) (#2114)
- Downloading multiple files streams the archive creation rather than using cacheDir -- thanks @janakoram (#2125) (#2130)
- `server.maxArchiveSizeGB` now defaults to 20 (GB) and only applies to archive/unarchive actions (not downloads).
- browser download progress bar will no longer show for archive downloads. this is the main drawback to the streaming approach.
- should allow for much higher parallel download support and lower cleanup maintenenance.
- [docker] ffmpeg version upgraded to 8.1
- remote ip in logs now prefers `X-Forwarded-For` if it exists, then `X-Real-IP`, then lastly the standard RemoteAddr. Useful when running behind a proxy to log the public IP of each request. (#2110)
- changed loading spinner style to be more compatible with safari browsers.
**BugFixes**:
- Wrong username in share settings (#2147) (#2148)
- [OnlyOffice] Error when saving a file under a user scope #2133
- Cannot edit shared file in OnlyOffice #2143
- PWA install button disappeared (#2086)
- Deleting a root folder was possible #2128
- PUT resource api errors if action against a folder (#2153)
- LDAP authentication issue if a password caontains @ symbol (#2154)
- Share banner seems to be not working for custom urls (#2120)
## v1.3.1-beta
**Security**:
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse) GHSA-r633-fcgp-m532
- Patched Incomplete Remediation of CVE-2026-27611: Password-Protected Share Bypass via /public/api/share/info GHSA-525j-95gf-766f
**New Features**:
- More user options for settings (#2072) (#2067):
- Option to disable thumbnails `userDefaults.preview.audio` and `userDefaults.preview.models` for Audio and 3D Models.
- Option to disable files in the Tree navigation `userDefaults.hideFilesInTree`
- Option to disable source files deletion when creating/extracting archives. `userDefaults.deleteAfterArchive`
**Notes**:
- Share icon does not show in share listing or for shares for other users.
- File Size Analyzer tool max items increased from 100 to 200.
- changed symlink detection logic.
**BugFixes**:
- 405 response code error on Webdav in 1.3.0-beta (#2054)
- Motion Preview setting not saving when changed in profile settings.
- Context menu on tools not working.
## v1.3.0-beta
Note: a potentially breaking change for docker users: \the default user is now "filebrowser" 1000:1000 instead of root.
**New Features**:
- New Sidebar Features
- Sidebar tree navigation (#2006) (#350)
- Source usage to be customized to show os-reported values rather than calculated. This can be changed per source by editing the source link in the sidebar. (#1266) (#982)
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
- new api to archive/unarchive files on the server
- requires `create` user permissions
- archiving actions respect `server.maxArchiveSize`
- Added share icon to items that are shared (#1420)
- Authentication enhancements
- LDAP login support with OIDC feature parity. (#591)
- userGroup for OIDC and LDAP, only users in a group will get access. (#1964)
- Add JWT token authentication support (#1364)
- Enhanced thumbnail and item previews
- Added ability to show "motion preview" for folders with multiple child items that have previews. cycles through the first 4 images.
- support for reading embedded images from raw image or heic/heif files (#215)
- reorganized and simplified thumbnail settings in profile settings (#1968)
- removed `highQuality` thumbnail option which only affected gallery view. Now its always enabled.
- improved caching for unsupported images, the same file won't be attempted again with the same modtime.
- supports 3d model previews.
- FileWatcher also supports watching directories
- Support previews for 3D model files (STL, OBJ, 3MF, etc.) (#1273)
- supported formats via threejs: GLTF, GLB, OBJ, STL, PLY, DAE (Collada), 3MF, 3DS, USDZ, USD, USDA, USDC, AMF, VRML, WRL, VTK, VTP, PCD, XYZ, VOX, KMZ
- supports animations (for formats that contain them)
- supports embedded textures, external neighboring file textures, or textures in `/textures` subdirectory
- Enhanced prompts
- All prompts have a taskbar with a close button
- Prompts can be freely moved by dragging taskbar
- Prompt styling has been updated
- Clicking outside of prompts no longer automatically closes them.
- add webdav support (#209) -- thanks to @reddac for (#1764)
- see docs on how to use
- requires api an un-customized api token as password
- respects access rules
- requires download permission to view and modify/create/delete permission to modify.
**Notes**:
- Docker images default to `filebrowser` user instead of root
- reorganized api routes
- consolidated tags for swagger to be more accurately grouped
- tools are all behind `/api/tools` routes
- `/api/raw` is deprecated (but functional). The `/api/resources/download` route will be used instead.
- `/api/preview` has been removed and replaced with `/api/resources/preview`
- `/api/onlyoffice` have been replaced with `/api/office`
- `/api/shares` has been moved to `/api/share/list`
- `/api/auth/tokens` has been moved to `/api/auth/token/list` and `/api/auth/token` added to get specific token info
- `PUT /api/token` has been moved to `POST /api/token`
- `/public/api/shareinfo` has been moved to `/public/api/share/info`
- `POST /resources/bulk/delete` api has been moved to `DELETE /resources/bulk` (#1984)
**BugFixes**:
- Long folder names get cut off at top navigation bar (#1934)
## v1.2.2-stable
**Security**:
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse)
- Patched Incomplete Remediation of CVE-2026-27611: Password-Protected Share Bypass via /public/api/share/info GHSA-525j-95gf-766f
**New Features**:
- "Divider" option in sidebar links to add a text or divider between links (#1875)
- "Divider" option in sidebar links to add a text or divider between links ()
- shares offer a "go to source Location" sidebar link and button when editing a share.
**Notes**:
- Share edit/delete permissions are scoped to the user's shares rather than global (#2050)
- OIDC group claims accepted as map (#2084)
**BugFixes**:
- fixed the requirement that the database path needed to be set in the config file, now it loads `FILEBROWSER_DATABASE` value by default, fallback to config file property.
+8 -69
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.
### 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`
+43 -56
View File
@@ -1,31 +1,30 @@
<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/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![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)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
[![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: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
: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.
This version is called "Quantum" because it packs tons of advanced features into a tiny easy to run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
This version is called "Quantum" because it packs tons of advanced features into a tiny and easy-to-run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
The goal for this repo is to become the best open-source self-hosted file browsing application that exists -- **all for free**. This repo will always be free and open-source.
@@ -35,67 +34,57 @@ Ready to try it out? See [Getting Started Docs](https://filebrowserquantum.com/e
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
1. ✅ Add and configure multiple sources
2. ✅ Login support for OIDC, password + 2FA, and proxy.
1. ✅ Better source configuration - multiple sources, include/exclude rules, and [more](https://filebrowserquantum.com/en/docs/configuration/sources/)
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 [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
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**
- Thumbnails support includes **office**, **video**, and **album artwork**, and **3D models**.
- Faster and more responsive views with animations.
- Folder sizes are displayed and support for thumbnails
- **Folder sizes** are displayed and support thumbnails
- Navigating remembers the last scroll position.
7. ✅ Highly configurable and customizable sharing options
- 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.
Notable features that this fork *does not* have (removed):
- :construction: jobs are not supported yet.
- ❌ shell commands are completely removed and will not be returned.
FileBrowser Quantum differs significantly from the original version. Many of these changes required a significant overhaul. Creating a fork was a necessary process to make the program better. There have been many growing pains, but a stable release is planned and coming soon.
## System Requirements
> [!WARNING]
> Every file and directory in the source gets indexed (by default). This enables powerful features such as instant search, but large source filesystems can increase your system requirements. [See indexing wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.
- **Memory**: depends on configured source complexity. See [how much RAM does it require?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
- **GPU**: Not currently used (planned)
## The UI
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 | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
@@ -104,47 +93,45 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
has standalone binary | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
price | free | free | free | free tier | free tier | $99+ |
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
Custom job support | :construction: | ✅ | ❌ | ✅ | ❌ | ✅ |
Advanced Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
tags support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ❌ | ❌ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
activity log | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
*Accessing files from FileBrowser via WebDav is a newly introduced and experimental feature.
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
+14 -15
View File
@@ -1,34 +1,35 @@
FROM gtstef/ffmpeg:8.0.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 upx
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 .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
WORKDIR /build
COPY ./frontend/package.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 i --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
@@ -37,13 +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" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+2 -1
View File
@@ -1,4 +1,5 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps firefox
RUN npx playwright install --with-deps chromium
+7 -5
View File
@@ -9,12 +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
+14
View File
@@ -0,0 +1,14 @@
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
COPY [ "./_docker/src/jwt/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
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
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+22
View File
@@ -0,0 +1,22 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/tests/", "./tests" ]
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
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/"]
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
+17
View File
@@ -0,0 +1,17 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/previews/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./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 mv .htpasswd /etc/nginx/conf.d/.htpasswd
+16
View File
@@ -0,0 +1,16 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
COPY [ "./_docker/src/screenshots/frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
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
+9 -6
View File
@@ -10,13 +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 -f /tests/playwright-files -p / -r user -v admin -allow -c config.yaml
RUN ./filebrowser set rule -f /tests/playwright-files -p /excluded -r user -v admin -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
+11 -12
View File
@@ -3,25 +3,25 @@ ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
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 apk update && apk add
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 .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
WORKDIR /build
COPY ./frontend/package.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 i
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
@@ -32,10 +32,9 @@ 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" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+35 -1
View File
@@ -1,4 +1,19 @@
services:
local-playwright:
container_name: local-playwright
ports:
- "8080:8080"
build:
dockerfile: ./_docker/Dockerfile.playwright-local
context: ../
local-playwright-screenshots:
container_name: local-playwright
volumes:
- ../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-screenshots
context: ../
nginx-proxy-auth:
image: nginx
container_name: nginx-proxy-auth
@@ -7,11 +22,30 @@ services:
volumes:
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
nginx-proxy-jwt:
image: nginx
container_name: nginx-proxy-jwt
ports:
- "8081:8081" # Admin user
- "8082:8082" # Regular user
- "8083:8083" # Wrong key
- "8084:8084" # No user
volumes:
- ./src/jwt/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/jwt/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
filebrowser-jwt:
hostname: filebrowser
volumes:
- '../frontend:/home/frontend'
- "./src/jwt/backend/config.yaml:/home/filebrowser/data/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile.slim
filebrowser:
hostname: filebrowser
volumes:
- '../frontend:/home/frontend'
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
- "./src/proxy/backend/config.yaml:/home/filebrowser/data/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile.slim
+7 -3
View File
@@ -1,5 +1,7 @@
server:
<<: *server-main
http:
<<: *http-main
frontend:
<<: *frontend-main
@@ -8,8 +10,10 @@ userDefaults:
image: true
popup: true
video: true
audio: true
models: true
office: true
highQuality: true
permissions:
download: true
create: true
account:
permissions:
share: true
+13 -4
View File
@@ -1,15 +1,24 @@
server-main: &server-main
http-main: &http-main
port: 80
baseURL: "/"
server-main: &server-main
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: true
create: true
- path: "."
name: "docker"
config:
disableIndexing: true
defaultEnabled: true
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
+53
View File
@@ -0,0 +1,53 @@
http:
port: 8080
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: true
defaultPermissions:
view: true
download: true
modify: true
create: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
auth:
methods:
password:
enabled: false
jwt:
enabled: true
secret: "test-jwt-secret-key-for-development-only-do-not-use-in-production"
algorithm: "HS256"
header: "X-JWT-Assertion"
userIdentifier: "sub"
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
+66
View File
@@ -0,0 +1,66 @@
# Admin user (port 8081)
server {
listen 8081;
server_name localhost 127.0.0.1;
set $admin_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NTc2NiwiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNTc2NiwibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDU3NjYsInN1YiI6InRlc3RhZG1pbiJ9.pxuKGyMmEe94m0xQ7y1QzcUZgU-lZSvqjoKUVoVd3F4";
location / {
proxy_set_header X-JWT-Assertion $admin_token;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# Regular user (port 8082)
server {
listen 8082;
server_name localhost 127.0.0.1;
set $regular_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJleHAiOjIwODc1NjU2OTIsImdyb3VwcyI6WyJteWdyb3VwIl0sImlhdCI6MTc3MjIwNTY5MiwibmFtZSI6IlRlc3QgVXNlciIsIm5iZiI6MTc3MjIwNTY5Miwic3ViIjoidGVzdHVzZXIifQ.X8fUYbhnZpgvP5CnGdms9eGo8yRLyNhRbj7xUSxEgl8";
location / {
proxy_set_header X-JWT-Assertion $regular_token;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# Valid token but signed with different secret (port 8083)
server {
listen 8083;
server_name localhost 127.0.0.1;
# Valid JWT token structure, but signed with wrong secret - should fail signature verification
set $wrong_key "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NjAyMywiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNjAyMywibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDYwMjMsInN1YiI6InRlc3RhZG1pbiJ9.afBobbJ3VQ-fKnUebGr-gxFIqegyICi6E3EgkUSeFNw";
location / {
proxy_set_header X-JWT-Assertion $wrong_key;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# No authentication (port 8084)
server {
listen 8084;
server_name localhost 127.0.0.1;
location / {
proxy_set_header X-JWT-Assertion "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// Simple JWT generator without external dependencies
func main() {
// Secret key - must match the one in your config file
secret := "wrong-key"
// Check if custom secret provided via command line
if len(os.Args) > 1 {
secret = os.Args[1]
}
// Create token claims with 10 year expiration
now := time.Now()
expiresAt := now.Add(10 * 365 * 24 * time.Hour) // 10 years
header := map[string]interface{}{
"alg": "HS256",
"typ": "JWT",
}
claims := map[string]interface{}{
"sub": "testadmin", // username (subject)
"email": "testadmin@example.com", // email claim
"name": "Test Admin", // full name
"groups": []string{"admin"}, // groups - includes admin for testing
"iat": now.Unix(), // issued at
"exp": expiresAt.Unix(), // expires at
"nbf": now.Unix(), // not before
}
// Encode header
headerJSON, err := json.Marshal(header)
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding header: %v\n", err)
os.Exit(1)
}
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
// Encode claims
claimsJSON, err := json.Marshal(claims)
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding claims: %v\n", err)
os.Exit(1)
}
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
// Create signature
message := headerB64 + "." + claimsB64
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(message))
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
// Combine to form complete JWT
tokenString := message + "." + signature
// Output the token
fmt.Println("=== JWT Token Generated ===")
fmt.Println()
fmt.Println("Token:")
fmt.Println(tokenString)
fmt.Println()
// Verify token can be decoded
parts := strings.Split(tokenString, ".")
if len(parts) == 3 {
fmt.Println("✓ Token structure is valid (3 parts)")
} else {
fmt.Fprintf(os.Stderr, "✗ Invalid token structure\n")
os.Exit(1)
}
// Save to file for easy access
filename := "test-jwt-token.txt"
if err := os.WriteFile(filename, []byte(tokenString), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not write token to file: %v\n", err)
} else {
fmt.Printf("✓ Token saved to: %s\n", filename)
fmt.Println()
}
// Also save to shell script for easy sourcing
scriptContent := fmt.Sprintf("#!/bin/bash\n# Source this file to set JWT token as environment variable\n# Usage: source jwt-env.sh\nexport TEST_JWT_TOKEN=\"%s\"\necho \"JWT token loaded into TEST_JWT_TOKEN\"\n", tokenString)
if err := os.WriteFile("jwt-env.sh", []byte(scriptContent), 0755); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not write shell script: %v\n", err)
} else {
fmt.Println("✓ Shell script saved to: jwt-env.sh")
fmt.Println(" Usage: source jwt-env.sh")
fmt.Println()
}
}
+3
View File
@@ -0,0 +1,3 @@
module test
go 1.25.6
@@ -0,0 +1,66 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/jwt-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/jwt",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for different JWT test scenarios */
projects: [
{
name: "admin-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8081", // Admin user JWT
},
},
{
name: "regular-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8082", // Regular user JWT
},
},
{
name: "wrong-key",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8083", // Wrong secret key
},
},
{
name: "no-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8084", // No JWT token
},
},
],
});
+19 -17
View File
@@ -1,13 +1,20 @@
server:
http:
port: 80
baseURL: "/files/"
server:
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "exclude"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: true
create: true
rules:
- folderPath: "excluded"
- folderPath: "excludedButVisible"
@@ -27,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:
@@ -53,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
+16 -10
View File
@@ -1,11 +1,17 @@
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: true
defaultPermissions:
view: true
download: true
modify: true
create: true
frontend:
name: "Graham's Filebrowser"
@@ -35,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
+41
View File
@@ -0,0 +1,41 @@
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:
noauth: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
userDefaults:
listing:
showHidden: true
preview:
image: true
popup: true
video: true
audio: true
office: true
models: true
highQuality: true
@@ -0,0 +1,54 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/previews-setup",
timeout: 10000,
testDir: "./tests/playwright/previews",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
workers: 10,
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
launchOptions: {
args: [
'--disable-gpu',
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
'--enable-webgl',
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
'--no-sandbox', // Required for Docker
],
},
storageState: "previews.json",
actionTimeout: 5000,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+14 -19
View File
@@ -1,13 +1,21 @@
server:
http:
port: 8080
baseURL: "/subpath"
baseURL: "/subpath"
trustProxyHeaders: true
server:
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: true
defaultPermissions:
view: true
download: true
modify: true
create: true
frontend:
name: "Graham's Filebrowser"
@@ -29,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
@@ -11,14 +11,14 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/proxy-setup.ts",
timeout: 5000,
timeout: 10000,
testDir: "./tests/playwright/proxy",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
retries: 3,
/* Opt out of parallel tests on CI. */
workers: 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
@@ -0,0 +1,12 @@
http:
port: 8080
baseURL: "/files/"
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright"
config:
defaultEnabled: true
@@ -0,0 +1,65 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/screenshots",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 1,
/* Opt out of parallel tests on CI. */
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
launchOptions: {
args: [
'--disable-gpu',
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
'--enable-webgl',
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
'--no-sandbox', // Required for Docker
],
},
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:8080",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "dark-screenshots",
use: {
...devices["Desktop Chrome"],
theme: 'dark',
},
},
{
name: "light-screenshots",
use: {
...devices["Desktop Chrome"],
theme: 'light',
},
},
],
});
+16 -4
View File
@@ -1,18 +1,26 @@
server:
http:
port: 80
baseURL: "/"
server:
database:
migrateFrom: "database.db.old"
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
defaultPermissions:
modify: false
- path: "."
name: "docker"
config:
disableIndexing: true
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
- path: "/tests/playwright-files"
name: "access"
config:
@@ -35,4 +43,8 @@ frontend:
auth:
methods:
password:
signup: true
signup: true
userDefaults:
listing:
showHidden: true
Binary file not shown.
@@ -11,7 +11,7 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/global-setup",
timeout: 3000,
timeout: 5000,
testDir: "./tests/playwright/settings",
/* Run tests in files in parallel */
fullyParallel: false,
+4 -2
View File
@@ -1,8 +1,10 @@
server:
http:
port: 80
baseURL: "/"
server:
logging:
- levels: "info|error|debug"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
+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:
-75
View File
@@ -1,75 +0,0 @@
package files
import (
"fmt"
"os"
"path/filepath"
"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"
)
// MakeUserDir makes the user directory according to settings.
func MakeUserDir(fullPath string) error {
if err := os.MkdirAll(fullPath, os.ModePerm); err != nil {
return err
}
return nil
}
func MakeUserDirs(u *users.User, disableScopeChange bool) error {
cleanedUserName := users.CleanUsername(u.Username)
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
}
for i, scope := range u.Scopes {
source, ok := settings.Config.Server.SourceMap[scope.Name]
if !ok {
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
continue
}
// create directory and append user name
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
// If parent directory doesn't exist and createUserDir is enabled, create it
if !Exists(parentDir) {
if err := MakeUserDir(parentDir); err != nil {
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
continue
}
}
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
// create directory exactly as specified
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if !Exists(parentDir) {
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
continue
}
err := MakeUserDir(fullPath)
if err != nil {
logger.Errorf("create user: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else {
// just assigning scope to path provided, so just check that it exists
path := filepath.Join(source.Path, scope.Scope)
if !Exists(path) {
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
continue
}
}
u.Scopes[i] = scope
}
return nil
}
@@ -1,46 +0,0 @@
package fileutils
import "testing"
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)
}
})
}
}
-34
View File
@@ -1,34 +0,0 @@
package auth
import (
"net/http"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var (
revokedApiKeyList map[string]struct{}
revokeMu sync.Mutex
)
func init() {
revokedApiKeyList = make(map[string]struct{})
}
// Auther is the authentication interface.
type Auther interface {
// Auth is called to authenticate a request.
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
}
func IsRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
}
func RevokeAPIKey(key string) {
revokeMu.Lock()
defer revokeMu.Unlock()
revokedApiKeyList[key] = struct{}{}
}
-114
View File
@@ -1,114 +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/database/users"
)
// JSONAuth is a json implementation of an Auther.
type JSONAuth struct {
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
}
// 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")
// 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)
}
totpCode := r.Header.Get("X-Secret")
// 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, err := userStore.Get(username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
err = users.CheckPwd(password, user.Password)
if err != nil {
return nil, err
}
// check for OTP for password
if user.TOTPSecret != "" {
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)
}
}
+96 -268
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,157 +22,44 @@ var (
configPath string
)
// return bool to indicate if the program should continue running
func runCLI() 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
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, dbConfig string
var asAdmin bool
var fsPath, 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(&fsPath, "f", "", "Real filesystem path (e.g. /mnt/storage) for rule command")
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)")
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "setup":
createConfig(configPath)
return false
case "set":
if len(os.Args) < 3 {
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
os.Exit(1)
}
subcommand := os.Args[2]
// Handle old syntax: "set -u user,pass -c config.yaml"
if subcommand == "-u" {
// Parse flags starting from os.Args[2:] to include the -u flag
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
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
}
// New pattern: subcommand-based (e.g., "set user" or "set rule")
// Parse flags starting from os.Args[3:] to skip the subcommand
err := setCmd.Parse(os.Args[3:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
switch subcommand {
case "rule":
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
default:
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", subcommand)
os.Exit(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
}
}
return true
}
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>")
}
// Initialize store and settings
_ = getStore(dbConfig) // ignore bool check
parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true)
if err != nil {
return err
}
// Apply the rule based on allow flag and ruleCategory
var err error
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'")
}
}
@@ -185,72 +72,61 @@ 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]
_ = getStore(dbConfig) // ignore bool check
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.Name,
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 {
logger.Infof("Creating non-admin user: %s\n", username)
}
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
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)
}
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 = 1
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)
}
@@ -258,53 +134,27 @@ func setUser(dbConfig string, asAdmin bool) error {
return nil
}
// UserDefaults defines default settings for new users.
type UserDefaults struct {
Permissions users.Permissions `yaml:"permissions"`
func promoteUser(username string) error {
user, err := state.GetUserByUsername(username)
if err != nil {
return fmt.Errorf("user %s not found", 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
}
// Frontend defines settings related to the web interface.
type Frontend struct {
Name string `yaml:"name,omitempty"`
}
// Source defines a directory to be served.
type Source struct {
Name string `yaml:"name,omitempty"`
Path string `yaml:"path"`
}
// Server defines server-specific configurations.
type Server struct {
Port int `yaml:"port"`
Database string `yaml:"database"`
Sources []Source `yaml:"sources"`
Logging []LogConfig `json:"logging"`
}
type LogConfig struct {
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Utc bool `json:"utc"` // use UTC time in the output instead of local time
}
type Auth struct {
AdminUsername string `yaml:"adminUsername"`
AdminPassword string `yaml:"adminPassword"`
}
// Settings is the top-level configuration structure.
type Settings struct {
Server Server `yaml:"server"`
Frontend Frontend `yaml:"frontend,omitempty"`
Auth Auth `yaml:"auth"`
UserDefaults UserDefaults `yaml:"userDefaults"`
}
// 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 askQuestion(reader *bufio.Reader, prompt string, defaultValue string) string {
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
input, _ := reader.ReadString('\n')
@@ -315,9 +165,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)
@@ -332,64 +179,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) {
// check if config file exists
if _, err := os.Stat("config.yaml"); err == nil {
fmt.Println("Config file 'config.yaml' already exists, skipping setup.")
return
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{
Server: Server{
Logging: []LogConfig{
{
Levels: "info|warning|error",
ApiLevels: "info|warning|error",
Output: "stdout",
NoColors: false,
Utc: false,
},
},
Sources: []Source{
{
Path: "",
},
},
},
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)
}
@@ -410,53 +238,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.Permissions.Modify = askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.UserDefaults.Permissions.Share = askYesNoQuestion(reader, "Should a new user be able to create shares 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() {
@@ -469,7 +298,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")
}
+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)
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)
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"
)
+66 -54
View File
@@ -11,53 +11,59 @@ import (
_ "net/http/pprof"
"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/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)
func initializeDatabase(configFile string) bool {
settings.Initialize(configFile)
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
logger.Fatalf("could not load db info: %v", err)
}
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() {
keepGoing := runCLI()
keepGoing, dbExists := runCLI()
if !keepGoing {
return
}
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.DatabaseV2.Path)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.DatabaseV2.Path)
}
if !settings.Config.Server.DisableUpdateCheck {
info, _ := utils.CheckForUpdates()
if info.LatestVersion != "" {
@@ -76,19 +82,19 @@ func StartFilebrowser() {
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
dbExists := getStore(configPath)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
// 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)
}
err = utils.SetInvalidPasswordHash()
if err != nil {
logger.Fatalf("Failed to set security hash: %v", err)
}
sourceList := []string{}
for path, source := range settings.Config.Server.SourceMap {
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
@@ -110,7 +116,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 {
@@ -123,26 +129,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
@@ -172,20 +176,22 @@ func StartFilebrowser() {
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
}
<-shutdownComplete
if err := fileutils.ClearDirectoryContents(settings.DownloadCacheDir()); err != nil {
logger.Warningf("failed to clear download spool on shutdown: %v", err)
}
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)
@@ -201,13 +207,19 @@ 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())
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
fbhttp.StartHttp(ctx, store, shutdownComplete)
// Initialize PWA manifest after icons are generated
icons.InitializePWAManifest()
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
}
+161 -109
View File
@@ -4,10 +4,13 @@ import (
"fmt"
"os"
"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/pkg/settings"
"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/go-logger/logger"
)
@@ -15,97 +18,135 @@ 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
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
changedFields = append(changedFields, "sidebarLinks")
}
if updateTokens(user) {
changedFields = append(changedFields, "tokens", "version")
}
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
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
if user.Username == adminUser && adminPass != "" && passwordEnabled {
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
changedFields = append(changedFields, "permissions", "password")
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)
}
}
err := store.Users.Save(user, changePass, true)
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 updateUserScopes(user *users.User) bool {
newScopes := []users.SourceScope{}
seen := make(map[string]struct{})
// Build map for existing scopes by Name
existing := make(map[string]users.SourceScope)
for _, s := range user.Scopes {
existing[s.Name] = 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 {
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
}
newScopes = append(newScopes, users.SourceScope{
Name: src.Path,
Scope: existingScope.Scope,
})
seen[src.Path] = struct{}{}
key := strings.ToLower(field)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, field)
}
return out
}
// Preserve user-defined scopes not matching current sources, append to end
for _, s := range user.Scopes {
if _, ok := seen[s.Name]; !ok {
newScopes = append(newScopes, s)
func updateUserScopes(user *users.User) bool {
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
user.BackendScopes = newScopes
return changed
}
func updateSourcePermissions(user *users.User) bool {
changed := false
if user.Version < users.SourcePermissionsMigrationVersion {
if users.MigrateToSourcePermissions(user) {
changed = true
}
}
changed := !reflect.DeepEqual(user.Scopes, newScopes)
user.Scopes = newScopes
if users.EnsureSourcePermissionsForScopes(
user,
settings.DefaultSourceFilePermissions(),
settings.AdminSourceFilePermissions(),
) {
changed = true
}
return changed
}
@@ -165,7 +206,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = 1
user.Version = 2
if updateUser {
createBackup = true
}
@@ -190,59 +231,70 @@ func updatePreviewSettings(user *users.User) bool {
return false
}
// updateSidebarLinks checks if user has stale source links and rebuilds them if needed
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
func updateSidebarLinks(user *users.User) bool {
// Count source links and check if any are still valid
sourceLinksCount := 0
validSourceLinksCount := 0
updated := false
for _, link := range user.SidebarLinks {
if link.Category == "source" {
sourceLinksCount++
// Check if this source still exists
if link.SourceName != "" {
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
validSourceLinksCount++
}
}
}
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
updated = true
}
// If user has no source links, don't update anything
if sourceLinksCount == 0 {
if !usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes) {
return updated
}
if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 && len(user.SidebarLinks) > 0 {
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
} else if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 {
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
} else {
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
}
merged, changed := usersidebar.EnsureSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
if changed {
user.SidebarLinks = merged
updated = true
}
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
updated = true
}
return updated
}
func updateTokens(user *users.User) bool {
if user.Version >= 2 {
return false
}
// If user has source links but NONE are valid, rebuild from their scopes
if validSourceLinksCount == 0 {
logger.Infof("User %s has %d stale source links, rebuilding from scopes", user.Username, sourceLinksCount)
// Remove all existing source links
newLinks := []users.SidebarLink{}
for _, link := range user.SidebarLinks {
if link.Category != "source" {
newLinks = append(newLinks, link)
}
if user.ApiKeys != nil {
user.Tokens = make(map[string]users.AuthToken)
for name, token := range user.ApiKeys {
token.Token = token.Key
token.Name = name
users.StoreToken(user.Tokens, token)
}
// Add new source links based on user's scopes
for _, scope := range user.Scopes {
if source, ok := settings.Config.Server.SourceMap[scope.Name]; ok {
// User has access to this source, add it to sidebar
newLinks = append(newLinks, users.SidebarLink{
Name: source.Name,
Category: "source",
Target: "/",
Icon: "",
SourceName: source.Path,
})
}
}
user.SidebarLinks = newLinks
return true
}
// User has at least one valid source link, no update needed
return false
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)
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)
}
}
+79
View File
@@ -0,0 +1,79 @@
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) {
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) {
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)
}
}
+67 -65
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"
)
@@ -25,6 +25,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
"sourceA": &sourceA,
"sourceB": &sourceB,
}
settings.InitializeUserResolvers()
// ---------------------
// Test Cases Definition
@@ -32,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"},
},
},
}
@@ -143,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)
})
}
@@ -156,7 +159,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
sourceA = settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
DefaultEnabled: true,
DefaultUserScope: "/defaultA",
CreateUserDir: true,
}}
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
@@ -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)
})
}
-135
View File
@@ -1,135 +0,0 @@
package settings
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/go-logger/logger"
)
// userAgentTransport wraps an http.RoundTripper to add a User-Agent header
type userAgentTransport struct {
base http.RoundTripper
userAgent string
}
// RoundTrip implements the http.RoundTripper interface
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
reqCopy := req.Clone(req.Context())
// Set User-Agent header if not already set
if reqCopy.Header.Get("User-Agent") == "" {
reqCopy.Header.Set("User-Agent", t.userAgent)
}
return t.base.RoundTrip(reqCopy)
}
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
Methods LoginMethods `json:"methods"`
Key string `json:"key"` // secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
AdminUsername string `json:"adminUsername"` // secret: the username of the admin user. If not set, the default is "admin".
AdminPassword string `json:"adminPassword"` // secret: the password of the admin user. If not set, the default is "admin".
TotpSecret string `json:"totpSecret"` // secret: secret used to encrypt TOTP secrets
AuthMethods []string `json:"-"`
}
type LoginMethods struct {
ProxyAuth ProxyAuthConfig `json:"proxy" validate:"omitempty"`
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
}
type PasswordAuthConfig struct {
Enabled bool `json:"enabled"`
MinLength int `json:"minLength" validate:"omitempty"` // minimum pasword length required, default is 5.
Signup bool `json:"signup" validate:"omitempty"` // allow signups on login page if enabled -- not secure.
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
EnforcedOtp bool `json:"enforcedOtp"` // if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"` // create user if not exists
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
}
type Recaptcha struct {
Host string `json:"host" validate:"required"`
Key string `json:"key" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
// OpenID OAuth2.0
type OidcConfig struct {
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
ClientID string `json:"clientId"` // secret: client id of the OIDC application
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
CreateUser bool `json:"createUser"` // create user if not exists
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges.
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
Provider *oidc.Provider `json:"-"` // OIDC provider
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
}
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
func validateOidcAuth() error {
oidcCfg := &Config.Auth.Methods.OidcAuth // Use a pointer to modify the original config
if oidcCfg.GroupsClaim == "" {
oidcCfg.GroupsClaim = "groups"
}
if oidcCfg.UserIdentifier == "" {
oidcCfg.UserIdentifier = "preferred_username"
}
if oidcCfg.Scopes == "" {
oidcCfg.Scopes = "openid email profile"
}
if !oidcCfg.Enabled {
return errors.New("OIDC is not enabled")
}
ctx := context.Background()
// Create a custom HTTP client with proper User-Agent to avoid being blocked by bot protection
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, // Respect HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables
}
// If disableVerifyTLS is true, disable TLS verification
if oidcCfg.DisableVerifyTLS {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
logger.Warning("OIDC TLS verification is disabled.")
}
// Create custom client with User-Agent header to bypass bot protection (like Cloudflare Bot Fight Mode)
customClient := &http.Client{
Transport: &userAgentTransport{
base: transport,
userAgent: fmt.Sprintf("FileBrowser Quantum - %s (OIDC Client)", version.Version),
},
}
// Use oidc.ClientContext to pass the custom client to the OIDC library
ctx = oidc.ClientContext(ctx, customClient)
provider, err := oidc.NewProvider(ctx, oidcCfg.IssuerUrl)
if err != nil {
return fmt.Errorf("url '%v' failed to create OIDC provider: %w", oidcCfg.IssuerUrl, err)
}
oidcCfg.Provider = provider
oidcCfg.Verifier = provider.Verifier(&oidc.Config{ClientID: oidcCfg.ClientID})
return nil
}
@@ -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")
}
-121
View File
@@ -1,121 +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 UserDefaultsPermissions) 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,
}
}
// Apply applies the default options to a user.
func ApplyUserDefaults(u *users.User) {
u.StickySidebar = Config.UserDefaults.StickySidebar
u.DisableSettings = Config.UserDefaults.DisableSettings
// Handle DarkMode with default - dereference pointer from config
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
// Handle Permissions - convert from pointer-based defaults to regular bools
u.Permissions.Api = Config.UserDefaults.Permissions.Api
u.Permissions.Admin = Config.UserDefaults.Permissions.Admin
u.Permissions.Modify = Config.UserDefaults.Permissions.Modify
u.Permissions.Share = Config.UserDefaults.Permissions.Share
u.Permissions.Realtime = Config.UserDefaults.Permissions.Realtime
u.Permissions.Delete = Config.UserDefaults.Permissions.Delete
u.Permissions.Create = Config.UserDefaults.Permissions.Create
u.Permissions.Download = boolValueOrDefault(Config.UserDefaults.Permissions.Download, true)
// Handle Preview - convert from pointer-based defaults to regular bools
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
u.Preview.HighQuality = boolValueOrDefault(Config.UserDefaults.Preview.HighQuality, true)
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
u.ThemeColor = Config.UserDefaults.ThemeColor
u.GallerySize = Config.UserDefaults.GallerySize
u.QuickDownload = Config.UserDefaults.QuickDownload
u.LockPassword = Config.UserDefaults.LockPassword
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.FileLoading = Config.UserDefaults.FileLoading
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
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,
})
}
}
}
}
-360
View File
@@ -1,360 +0,0 @@
package settings
import (
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
type AllowedMethods string
var Env Environment
const (
ProxyAuth AllowedMethods = "proxyAuth"
NoAuth AllowedMethods = "noAuth"
PasswordAuth AllowedMethods = "passwordAuth"
)
type Settings struct {
Server Server `json:"server"`
Auth Auth `json:"auth"`
Frontend Frontend `json:"frontend"`
UserDefaults UserDefaults `json:"userDefaults"`
Integrations Integrations `json:"integrations"`
}
type Environment struct {
IsPlaywright bool `json:"-"`
IsDevMode bool `json:"-"`
IsFirstLoad bool `json:"-"` // used internally to track if this is the first load of the application
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
FFmpegPath string `json:"-"`
FFprobePath string `json:"-"`
LoginIconPath string `json:"-"` // resolved login icon path (filesystem or embedded)
LoginIconIsCustom bool `json:"-"` // true if login icon is from custom filesystem path
LoginIconEmbeddedPath string `json:"-"` // embedded asset path for default icon
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
PWAIconsDir string `json:"-"` // directory where generated PWA icons are stored
PWAIcon192 string `json:"-"` // path to 192x192 PWA icon
PWAIcon256 string `json:"-"` // path to 256x256 PWA icon
PWAIcon512 string `json:"-"` // path to 512x512 PWA icon
}
type Server struct {
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Port int `json:"port"` // port to listen on
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging" yaml:"logging"`
Database string `json:"database"` // path to the database file
Sources []*Source `json:"sources" validate:"required,dive"`
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
// not exposed to config
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
}
type Filesystem struct {
CreateFilePermission string `json:"createFilePermission" validate:"required,file_permission"` // Unix permissions like 644, 755, 2755 (default: 644)
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
}
type IndexSqlConfig struct {
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
}
type Integrations struct {
OnlyOffice OnlyOffice `json:"office" validate:"omitempty"`
Media Media `json:"media" validate:"omitempty"`
}
// onlyoffice secret is stored in the local.json file
// docker exec <containerID> /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string'
type OnlyOffice struct {
Url string `json:"url" validate:"required"` // The URL to the OnlyOffice Document Server, needs to be accessible to the user.
InternalUrl string `json:"internalUrl"` // An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
Secret string `json:"secret" validate:"required"` // secret: authentication key for OnlyOffice integration
ViewOnly bool `json:"viewOnly"` // view only mode for OnlyOffice
}
type Media struct {
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // extract embedded subtitles from media files
}
type FfmpegConvert struct {
ImagePreview map[ImagePreviewType]*bool `json:"imagePreview"` // supported image preview formats. defaults vary by type (see individual type docs)
VideoPreview map[VideoPreviewType]*bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
}
type ImagePreviewType string
const (
HEICImagePreview ImagePreviewType = "heic"
JPEGImagePreview ImagePreviewType = "jpeg" // only used as fallback for JPEG formats that can't otherwise be decoded
//RAWImagePreview ImagePreviewType = "raw"
)
func (i ImagePreviewType) String() string {
return string(i)
}
// AllImagePreviewTypes contains all supported image preview types.
var AllImagePreviewTypes = []ImagePreviewType{
HEICImagePreview,
JPEGImagePreview,
//RAWImagePreview,
}
type VideoPreviewType string
const (
MP4VideoPreview VideoPreviewType = "mp4"
WebMVideoPreview VideoPreviewType = "webm"
MOVVideoPreview VideoPreviewType = "mov"
AVIVideoPreview VideoPreviewType = "avi"
MKVVideoPreview VideoPreviewType = "mkv"
FLVVideoPreview VideoPreviewType = "flv"
WMVVideoPreview VideoPreviewType = "wmv"
M4VVideoPreview VideoPreviewType = "m4v"
ThreeGPVideoPreview VideoPreviewType = "3gp"
ThreeGP2VideoPreview VideoPreviewType = "3g2"
TSVideoPreview VideoPreviewType = "ts"
M2TSVideoPreview VideoPreviewType = "m2ts"
VOBVideoPreview VideoPreviewType = "vob"
ASFVideoPreview VideoPreviewType = "asf"
MPGVideoPreview VideoPreviewType = "mpg"
MPEGVideoPreview VideoPreviewType = "mpeg"
F4VVideoPreview VideoPreviewType = "f4v"
OGVVideoPreview VideoPreviewType = "ogv"
)
func (v VideoPreviewType) String() string {
return string(v)
}
// AllVideoPreviewTypes contains all supported video preview types.
var AllVideoPreviewTypes = []VideoPreviewType{
MP4VideoPreview,
WebMVideoPreview,
MOVVideoPreview,
AVIVideoPreview,
MKVVideoPreview,
FLVVideoPreview,
WMVVideoPreview,
M4VVideoPreview,
ThreeGPVideoPreview,
ThreeGP2VideoPreview,
TSVideoPreview,
M2TSVideoPreview,
VOBVideoPreview,
ASFVideoPreview,
MPGVideoPreview,
MPEGVideoPreview,
F4VVideoPreview,
OGVVideoPreview,
}
type LogConfig struct {
Levels string `json:"levels" yaml:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels" yaml:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output" yaml:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors" yaml:"noColors"` // disable colors in the output
Json bool `json:"json" yaml:"json"` // output in json format
Utc bool `json:"utc" yaml:"utc"` // use UTC time in the output instead of local time
}
type Source struct {
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
Name string `json:"name"` // display name
Config SourceConfig `json:"config,omitempty"`
}
type SourceConfig struct {
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // deprecated: create a rule with indexingIntervalMinutes to set the indexing interval for this source
DisableIndexing bool `json:"disableIndexing,omitempty"` // deprecated: use indexingDisabled instead to disable the indexing of this source
Conditionals ConditionalFilter `json:"conditionals"` // deprecated: use source.rules instead
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
// hidden but used internally - optimized map lookups for conditional rules
ResolvedRules ResolvedRulesConfig `json:"-"`
}
type ConditionalFilter struct {
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
IgnoreHidden bool `json:"ignoreHidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // deprecated: use ignoreZeroSizeFolders instead. eg, FolderPath: "/" and ignoreZeroSizeFolders: true will ignore folders with 0 size under the root folder.
ItemRules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
}
type ConditionalRule struct {
NeverWatchPath string `json:"neverWatchPath"` // index the folder in the first pass to get included in search, but never re-indexed.
IncludeRootItem string `json:"includeRootItem"` // include only these items at root folder level
FileStartsWith string `json:"fileStartsWith"` // (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
FolderStartsWith string `json:"folderStartsWith"` // (global) exclude folders that start with these prefixes. Eg. "archive-" or "backup-"
FileEndsWith string `json:"fileEndsWith"` // (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
FolderEndsWith string `json:"folderEndsWith"` // (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
FolderPath string `json:"folderPath"` // (global) exclude folders that match this path. Eg. "/path/to/folder" or "/path/to/folder/subfolder"
FilePath string `json:"filePath"` // (global) exclude files that match this path. Eg. "/path/to/file.txt" or "/path/to/file.txt/subfile.txt"
FileNames string `json:"fileNames"` // deprecated: exclude files that match these names. Eg. "file.txt" or "test.csv"
FolderNames string `json:"folderNames"` // deprecated: exclude folders that match these names. Eg. "folder" or "subfolder"
FileName string `json:"fileName"` // (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
FolderName string `json:"folderName"` // (global) exclude folders that match these names. Eg. "folder" or "subfolder"
Viewable bool `json:"viewable"` // Enable viewing in UI but exclude from indexing
IgnoreHidden bool `json:"ignoreHidden"` // Excludes only hidden files and folders
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // Excludes only folders with 0 size
IgnoreSymlinks bool `json:"ignoreSymlinks"` // Excludes symbolic links
}
// ConditionalMaps provides O(1) lookup performance for conditional rules
// Maps are built from ConditionalFilter during initialization
type ResolvedRulesConfig struct {
// Exact match maps - O(1) lookup (only for names, not StartsWith/EndsWith)
FileNames map[string]ConditionalRule // key: file name
FolderNames map[string]ConditionalRule // key: folder name
// exact match for paths
FilePaths map[string]ConditionalRule // key: file path
FolderPaths map[string]ConditionalRule // key: folder path
FileEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
FolderEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
FileStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
FolderStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
// NeverWatch paths map - O(1) lookup for all paths with neverWatch: true
// This replaces the old NeverWatchPaths slice
NeverWatchPaths map[string]struct{} // key: full folder path
IncludeRootItems map[string]struct{} // key: inclusive root item
IgnoreAllHidden bool // Excludes all hidden files and folders
IgnoreAllZeroSizeFolders bool // Excludes all folders with 0 size
IgnoreAllSymlinks bool // Excludes all symbolic links
IndexingDisabled bool // Excludes all files and folders from indexing
}
type Frontend struct {
Name string `json:"name"` // display name
DisableDefaultLinks bool `json:"disableDefaultLinks"` // disable default links in the sidebar
DisableUsedPercentage bool `json:"disableUsedPercentage"` // disable used percentage for the sources in the sidebar
ExternalLinks []ExternalLink `json:"externalLinks"`
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
Styling StylingConfig `json:"styling"`
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
}
type StylingConfig struct {
DisableEventBasedThemes bool `json:"disableEventThemes"` // disable the event based themes,
CustomCSS string `json:"customCSS"` // if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
CustomCSSRaw string `json:"-"` // The css raw content to use for the custom css.
LightBackground string `json:"lightBackground"` // specify a valid CSS color property value to use as the background color in light mode
DarkBackground string `json:"darkBackground"` // Specify a valid CSS color property value to use as the background color in dark mode
CustomThemes map[string]CustomTheme `json:"customThemes"` // A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
// In-memory (not exposed to config)
CustomThemeOptions map[string]CustomTheme `json:"-"` // not exposed
}
type CustomTheme struct {
Description string `json:"description"` // The description of the theme to display in the UI.
CSS string `json:"css,omitempty"` // The css file path and filename to use for the theme.
CssRaw string `json:"-"` // The css raw content to use for the theme.
}
type ExternalLink struct {
Text string `json:"text" validate:"required"` // the text to display on the link
Title string `json:"title"` // the title to display on hover
Url string `json:"url" validate:"required"` // the url to link to
}
// UserDefaultsPreview holds preview settings with pointer types for defaults
type UserDefaultsPreview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
HighQuality *bool `json:"highQuality"` // use high quality thumbnails
Image *bool `json:"image"` // show thumbnails for image files
Video *bool `json:"video"` // show thumbnails for video files
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office *bool `json:"office"` // show thumbnails 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 the styled feature-rich media player for browser default
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
}
// UserDefaultsPermissions holds permission settings with pointer types for defaults
type UserDefaultsPermissions 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
}
// UserDefaults is a type that holds the default values
// for some fields on User.
type UserDefaults 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
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
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
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
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
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
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
Preview UserDefaultsPreview `json:"preview"`
DefaultScopes []users.SourceScope `json:"-"`
Permissions UserDefaultsPermissions `json:"permissions"`
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor 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
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
}
-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
-81
View File
@@ -1,81 +0,0 @@
package utils
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// GetChecksum calculates the checksum of a file using the specified algorithm.
// Returns the checksum as a hex-encoded string.
func GetChecksum(fullPath, algo string) (string, error) {
reader, err := os.Open(fullPath)
if err != nil {
return "", err
}
defer reader.Close()
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
h, ok := hashFuncs[algo]
if !ok {
return "", errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// FileOptions are the options when getting a file info.
type FileOptions struct {
Path string // realpath
Source string
IsDir bool
Expand bool
Content bool
Recursive bool // whether to recursively index directories
Metadata bool // whether to get metadata
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
ShowHidden bool // whether to show hidden files (true = show, false = hide)
FollowSymlinks bool // whether to follow symlinks
}
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
// Rule 1: Do Not Use User Input in File Paths (without validation)
func SanitizeUserPath(userPath string) (string, error) {
clean := filepath.Clean(userPath)
// Split the path into segments to check for path traversal attempts
// We check if ".." appears as a complete path segment (not just in a filename)
segments := strings.Split(clean, string(filepath.Separator))
for _, segment := range segments {
// Check if any segment is exactly ".." (path traversal attempt)
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
if segment == ".." {
return "", fmt.Errorf("invalid path: path traversal detected")
}
}
return clean, nil
}
-11
View File
@@ -1,11 +0,0 @@
server:
logging:
- levels: "info|warning|error"
sources:
- path: "/srv"
userDefaults:
preview:
image: true
popup: true
video: true
highQuality: true
+64
View File
@@ -0,0 +1,64 @@
http:
port: 8080
baseURL: "/"
server:
database: "playwright-files.db"
logging:
- levels: "info|warning|debug|error"
sources:
- path: "../frontend/playwright-files"
config:
defaultEnabled: true
defaultPermissions:
view: true
download: true
modify: false
create: false
delete: false
userDefaults:
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:
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"
+12 -46
View File
@@ -1,51 +1,17 @@
server:
http:
port: 80
baseURL: "/"
server:
logging:
- levels: "info|warning|error"
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
sources:
- path: "/srv"
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 (defaults to true if not set)
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
preview:
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
highQuality: true # generate high quality thumbnail preview images (defaults to true if not set)
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)
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)
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
defaultMediaPlayer: false # disable html5 media player and use the default media player
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
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)
disableUpdateNotifications: false # disable update notifications banner for admin users
deleteWithoutConfirming: false # delete files without confirmation
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
customTheme: "" # Name of theme to use chosen from custom themes config.
showSelectMultiple: false # show select multiple files on desktop
debugOffice: false # debug onlyoffice editor
auth:
methods:
password:
enabled: true
minLength: 5
signup: false
passkey:
enabled: false
-75
View File
@@ -1,75 +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)
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
SourceURL string `json:"sourceURL,omitempty"`
CanEdit bool `json:"canEdit,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"`
}
-356
View File
@@ -1,356 +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
shareCache map[string]*Link
mu sync.RWMutex
users *users.Storage
}
// NewStorage creates a share links storage from a backend.
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
return &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareCache: make(map[string]*Link),
users: usersStore,
}
}
// All wraps StorageBackend.All and handles expiry.
func (s *Storage) All() ([]*Link, error) {
links, err := s.back.All()
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
s.mu.Unlock()
return filtered, nil
}
// FindByUserID wraps StorageBackend.FindByUserID and handles expiry.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
links, err := s.back.FindByUserID(id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
s.mu.Unlock()
return filtered, 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.shareCache[hash]; ok && link != nil {
s.mu.RUnlock()
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
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.mu.Lock()
s.shareCache[hash] = link
s.mu.Unlock()
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.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
}
return l, err
}
// Gets wraps StorageBackend.Gets and handles expiry.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
s.mu.Unlock()
return filtered, nil
}
// GetBySourcePath wraps StorageBackend.GetBySourcePath and handles expiry.
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
links, err := s.back.GetBySourcePath(path, source)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
s.mu.Unlock()
return filtered, nil
}
// 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.back.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.mu.Lock()
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
existing.Source = l.Source
existing.Path = l.Path
} else {
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
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.mu.Lock()
if existing, ok := s.shareCache[hash]; ok && existing != nil {
existing.Path = newPath
}
s.mu.Unlock()
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.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
return nil
}
// Delete wraps StorageBackend.Delete
func (s *Storage) Delete(hash string) error {
if err := s.back.Delete(hash); err != nil {
return err
}
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
return nil
}
// Flush persists the current in-memory state of all shares to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
s.mu.RLock()
links := make([]*Link, 0, len(s.shareCache))
for _, l := range s.shareCache {
if l != nil {
links = append(links, l)
}
}
s.mu.RUnlock()
for _, l := range links {
if err := s.back.Save(l); err != nil {
return err
}
}
return nil
}
// filterExpired removes expired links and deletes them from storage.
func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
var filtered []*Link
for _, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
s.mu.Lock()
delete(s.shareCache, link.Hash)
s.mu.Unlock()
continue
}
filtered = append(filtered, link)
}
return filtered, 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
}
-323
View File
@@ -1,323 +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/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/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
}
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
return errors.ErrNoTotpConfigured
}
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
fields, err = parseFields(user, fields, actorIsAdmin)
if err != nil {
return err
}
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
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
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, true)
if err != nil {
logger.Error(err.Error())
}
}
// 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)
}
}
// last revoke api keys if needed.
if existingUser.Permissions.Api && !user.Permissions.Api && slices.Contains(fields, "Permissions") {
for _, key := range existingUser.ApiKeys {
auth.RevokeAPIKey(key.Key) // add to blacklist
}
}
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 := users.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, disableScopeChange)
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.Ptr {
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", "apikeys", "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.Ptr {
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 := users.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
}
-529
View File
@@ -1,529 +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/database/users"
)
func init() {
users.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
}
-138
View File
@@ -1,138 +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()
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.Permissions.Admin = true
user.Scopes = []users.SourceScope{}
for _, val := range settings.Config.Server.Sources {
user.Scopes = append(user.Scopes, users.SourceScope{
Name: val.Path, // backend name is path
Scope: "",
})
}
user.LockPassword = false
user.Permissions = settings.AdminPerms()
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 := users.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
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
}
-156
View File
@@ -1,156 +0,0 @@
package users
import (
"fmt"
"strings"
)
// 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 converts the user's scopes from frontend-style to backend-style
func (u *User) GetBackendScopes() ([]SourceScope, error) {
// Only convert scopes if they are not empty
// Empty scopes during update should remain empty (not filled with defaults)
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 {
// First check if its already a path name and keep it
source, ok := sourceConfig.GetSourceByPath(scope.Name)
if ok {
if scope.Scope == "" {
scope.Scope = source.DefaultUserScope
}
scope.Scope = normalizeScope(scope.Scope)
newScopes = append(newScopes, SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
continue
}
// Check if its the name of a source and convert it to a path
source, ok = sourceConfig.GetSourceByName(scope.Name)
if !ok {
// source might no longer be configured
continue
}
if scope.Scope == "" {
scope.Scope = source.DefaultUserScope
}
scope.Scope = normalizeScope(scope.Scope)
newScopes = append(newScopes, SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
}
return newScopes, nil
}
// GetFrontendScopes converts the user's scopes from backend-style to frontend-style
// Backend scopes use source paths, frontend scopes use source names
func (u *User) GetFrontendScopes() []SourceScope {
if sourceConfig == nil {
return []SourceScope{}
}
newScopes := []SourceScope{}
for _, scope := range u.Scopes {
if source, ok := sourceConfig.GetSourceByPath(scope.Name); ok {
// Replace scope.Name with source.Name while keeping the same Scope value
newScopes = append(newScopes, SourceScope{
Name: source.Name,
Scope: scope.Scope,
})
}
}
return newScopes
}
// GetBackendSidebarLinks converts the user's sidebar links from frontend-style to backend-style
// Validates that sources exist and converts source names to paths
func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
if sourceConfig == nil {
return nil, fmt.Errorf("source config not initialized")
}
newLinks := []SidebarLink{}
for _, link := range u.SidebarLinks {
// For source links, validate that the source exists using SourceName
if 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)
if !ok {
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
}
link.SourceName = sourceInfo.Path // if name changes keep link alive
}
// Store the link as-is with all fields preserved
newLinks = append(newLinks, link)
}
return newLinks, nil
}
// GetFrontendSidebarLinks converts the user's sidebar links from backend-style to frontend-style
// Converts source paths to source names for the frontend
func (u *User) GetFrontendSidebarLinks() []SidebarLink {
if sourceConfig == nil {
return []SidebarLink{}
}
newLinks := []SidebarLink{}
for _, link := range u.SidebarLinks {
// For source links, validate that the source still exists
if link.Category == "source" {
if link.SourceName == "" {
continue
}
// Check if source exists
sourceInfo, ok := sourceConfig.GetSourceByPath(link.SourceName)
if !ok {
continue
}
link.SourceName = sourceInfo.Name
}
// For share links, just pass through (shares are validated separately)
// For all other links, pass through as-is
newLinks = append(newLinks, link)
}
return newLinks
}
// 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
}
-18
View File
@@ -1,18 +0,0 @@
package users
import (
"golang.org/x/crypto/bcrypt"
)
var BcryptCost = bcrypt.DefaultCost
// 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))
}
Loaded 100 of 852 files, more files were not shown because too many files have changed in this diff. Show more