Compare commits

..
107 Commits
Author SHA1 Message Date
Graham Steffaniak eb518bac56 updated build for windows (#1859) 2026-01-20 09:24:18 -06:00
Graham Steffaniak d82e7f2986 updated changelog 2026-01-20 08:59:43 -06:00
Graham Steffaniak 7cd3f8374e added last minute bugfixes (#1857) 2026-01-20 08:50:40 -06:00
Graham Steffaniak 4e0f0453f3 updated sharing features (#1852) 2026-01-19 13:48:35 -06:00
Maxime Borges 98246b2981 i18n: Remove unecessary colon for API keys title (#1848) 2026-01-19 10:21:31 -06:00
Maxime Borges d2c07ffd3c settings: do not center settings title (#1849) 2026-01-19 10:21:17 -06:00
Graham SteffaniakandKevin B. 43cbadbeb8 UI improvements shelf (#1843)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-01-18 17:26:39 -06:00
Kevin B. 25dc426522 Change to plyr package directly + fixes and improvements (#1833) 2026-01-16 20:47:35 -05:00
Graham Steffaniak 27dbd509b2 Bugfix 1.2.0 (#1840) 2026-01-16 11:22:20 -06:00
Graham Steffaniak 3716c4e4c6 Change quick scan and delete purge (#1835) 2026-01-15 10:34:14 -06:00
Graham Steffaniak 7aabdadf3e Improved api format (#1834) 2026-01-14 18:21:26 -06:00
Graham Steffaniak 264de1ce88 more consistent permissions, functions (#1832) 2026-01-14 11:06:19 -06:00
Graham Steffaniak 4aae24cf11 bulk delete enhancements + duplicate detector deletion feature (#1830) 2026-01-13 11:00:33 -06:00
Graham Steffaniak 0968c44d22 updated scrolling 2026-01-12 14:21:01 -06:00
Graham Steffaniak a15fe4eaa5 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-12 12:12:14 -06:00
Graham Steffaniak a433a8a8d4 make disk check and rules more efficient (#1822) 2026-01-12 11:35:25 -06:00
Graham Steffaniak b5219ae6b6 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-12 10:46:19 -06:00
Graham SteffaniakandSmilingPie d9f91c7c65 Chunked downloading (#1821)
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
2026-01-10 11:12:32 -06:00
Kevin B. 4a85746850 Allow rename in previews (#1817) 2026-01-10 11:03:00 -06:00
Graham Steffaniak 3ae8aede25 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-09 17:31:33 -06:00
Graham Steffaniak 3990890721 Update login encoding (#1816) 2026-01-09 12:48:26 -06:00
Graham SteffaniakandSmilingPie 726116d831 Search multiple sources (#1815)
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
2026-01-08 13:35:51 -06:00
Graham Steffaniak 8274f2c715 Merge branch 'dev/v1.2.0' of github.com:gtsteffaniak/filebrowser into dev/v1.2.0 2026-01-08 10:07:48 -06:00
Graham SteffaniakandSmilingPie 32969d0777 Merge v1.1.6 1.2.0 (#1813)
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
2026-01-07 09:11:56 -06:00
Graham Steffaniak 8f25186136 Merge branch 'main' into dev/v1.2.0 2026-01-07 08:40:48 -06:00
Graham Steffaniak e18a73c97a two pass loading with loading bar (#1800) 2026-01-05 10:45:56 -06:00
Aleksandr Baryshnikov e5cb88ba5d feat: initial support for webdav (#1764) 2026-01-05 09:22:51 -06:00
Kevin B.andGraham Steffaniak 50b9ee8b31 make breadcrumbs act as a drop area (#1785)
Co-authored-by: Graham Steffaniak <gtsteff@gmail.com>
2026-01-04 16:45:38 -06:00
Graham Steffaniak a0e266fadf updated with more indexing changes (#1793) 2026-01-04 13:51:10 -06:00
Graham Steffaniak 377ae33e1b refactor folder size updates (#1782) 2026-01-01 18:36:35 -06:00
Kevin B. e14f2d7a4b Fix fileWatcher file selection + spacebar shortcut (#1770) 2025-12-31 17:16:52 -05:00
Graham Steffaniak fac091f1b3 Merge branch 'main' into dev/v1.2.0 2025-12-31 13:20:37 -06:00
Graham Steffaniak 5330d6b093 updated changelog 2025-12-31 13:19:19 -06:00
Graham Steffaniak 692f99251d updated with a few more small updates (#1768) 2025-12-29 16:09:07 -06:00
Graham Steffaniak 31e70519c1 updated loading spinners (#1766) 2025-12-29 11:05:34 -06:00
Graham Steffaniak 0b88a90048 Merge branch 'main' into dev/v1.2.0 2025-12-28 15:55:05 -06:00
Graham Steffaniak 79d7f07943 updated conditional rules format (#1761) 2025-12-28 15:49:58 -06:00
Graham Steffaniak b562edaaeb make indexing data persistent within cacheDir (#1760) 2025-12-28 11:30:07 -06:00
Graham Steffaniak 33ede042da efficiency tweaks for indexing (#1759) 2025-12-28 09:20:08 -06:00
Graham Steffaniak 036a0ed6c8 updated indexing functionality (#1755) 2025-12-26 16:22:02 -06:00
Graham Steffaniak 1842ff561b updated root scanner not to purge 2025-12-21 08:15:44 -06:00
Graham Steffaniak aed9f7c135 add tail/watch file watcher tool (#1738) 2025-12-20 15:40:29 -06:00
Graham Steffaniak c6600a0665 updated memory pressure (#1741) 2025-12-20 14:10:20 -06:00
Graham Steffaniak 3e3d3decc6 updated indexing design (#1728) 2025-12-16 15:14:50 -06:00
Graham Steffaniak c58b298eb5 Merge branch 'beta/v1.1.4' into dev/v1.2.0 2025-12-13 14:40:49 -06:00
Graham Steffaniak 98ba8095db updated build 2025-12-13 09:54:44 -06:00
Graham Steffaniak 68fb573059 improved cacheDir checks (#1723) 2025-12-13 08:50:05 -06:00
Graham Steffaniak 5747c40dca updated translation 2025-12-12 18:00:04 -06:00
Graham Steffaniak 598bd366db added search image show toggle (#1721) 2025-12-12 17:58:20 -06:00
Graham Steffaniak e3be3300dc File list loading spinner (#1720) 2025-12-12 15:44:27 -06:00
Graham Steffaniak 416aa75446 updated with new logger (#1719) 2025-12-12 15:24:27 -06:00
Graham Steffaniak cfb3b9e869 updated with fix for special characters (#1718) 2025-12-12 14:23:30 -06:00
Graham Steffaniak 166f1bf232 invalidate isDir cache (#1716) 2025-12-12 14:06:41 -06:00
Graham Steffaniak 451f9ac69f update with access rules for shares (#1714) 2025-12-12 12:35:25 -06:00
Graham Steffaniak b2fa1780ed updated with access rule changes (#1713) 2025-12-12 11:27:49 -06:00
Kevin B. 9255bc174b Fix/Improve some behaviors in nextPrevious (#1707) 2025-12-10 16:14:55 -06:00
Kevin B. a3a074d7b6 Fix navigation for files with special characters (#1711) 2025-12-10 14:12:21 -06:00
Claudio YanesandGraham Steffaniak aefe16b508 Implements socket activation (#1698)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2025-12-10 11:54:50 -06:00
Graham Steffaniak 728ee72e2a updated with indexing test fixes (#1699) 2025-12-03 19:33:42 -06:00
Graham Steffaniak e94075de6c updated scanner complexity 2025-12-02 11:08:28 -06:00
Graham Steffaniak c56bafb4e9 fixed some indexing issues 2025-12-02 10:58:37 -06:00
Graham Steffaniak a6e045cc85 Merge tag 'v1.1.3-beta' into experimental-sqlite-indexing 2025-12-02 09:55:22 -06:00
Graham Steffaniak 690685459e updated favicon to svg only (#1694) 2025-12-02 09:09:11 -06:00
Graham Steffaniak 6f6244b2fb revert download behavior (#1691) 2025-12-02 08:54:45 -06:00
Graham Steffaniak 05fcd31909 Clear access cache aggressively (#1690) 2025-12-01 18:28:07 -06:00
Graham Steffaniak a4107a734a continue user dir creation on error (#1689) 2025-12-01 18:06:04 -06:00
Graham Steffaniak 991be79521 scanner size increment changes (#1688) 2025-12-01 17:33:18 -06:00
Graham Steffaniak 8755e82780 improved sqlite implementation 2025-11-30 08:01:14 -06:00
Graham Steffaniak d1b3a045af updated with exclusive lock 2025-11-29 17:51:06 -06:00
Graham Steffaniak b5b365c6a7 Merge branch 'experimental-sqlite-indexing' of github.com:gtsteffaniak/filebrowser into experimental-sqlite-indexing 2025-11-29 17:46:41 -06:00
Graham Steffaniak c33a876033 improved sql indexing 2025-11-29 17:43:20 -06:00
Graham Steffaniak 42778b92bb updated with tuning 2025-11-28 19:59:15 -06:00
Graham Steffaniak ff3b66b79d adjust cache settings 2025-11-28 19:59:15 -06:00
Graham Steffaniak daedeef78d expirmental features 2025-11-28 19:59:15 -06:00
Graham Steffaniak edba1a80ef updated with a few fixes (#1678) 2025-11-28 12:08:17 -06:00
Graham Steffaniak 77fb6bdabc updated with tuning 2025-11-27 14:40:43 -06:00
Graham Steffaniak 97c37caa1c adjust cache settings 2025-11-26 18:37:39 -06:00
Graham Steffaniak 02b28284ca expirmental features 2025-11-26 18:18:12 -06:00
Graham Steffaniak 6a489c6f7c Fix sidebar custom link validation (#1665) 2025-11-26 16:27:29 -06:00
Kevin B.andGraham Steffaniak ba443398ca Improve captions styles in plyr (#1661)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2025-11-26 16:06:52 -06:00
Graham Steffaniak 17ff68c00d fix utf-8 filename prefix (#1664) 2025-11-26 16:06:34 -06:00
Graham Steffaniak cb71dd8940 fixed concurrent media duration issue (#1663) 2025-11-26 15:24:12 -06:00
Graham Steffaniak 764155d612 updated indexing changes (#1662) 2025-11-26 14:42:43 -06:00
Graham Steffaniak 9013152d9d Merge branch 'dev/v1.1.2' of github.com:gtsteffaniak/filebrowser into dev/v1.1.2 2025-11-26 09:14:09 -06:00
27bc000cda Experiment with sqllite (#1660)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-26 09:13:50 -06:00
8f4e420e73 Experiment with sqllite (#1660)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-26 09:12:09 -06:00
Graham Steffaniak e4d8a5cf20 fix editor issues (#1654) 2025-11-24 13:07:59 -06:00
Kevin B.andGraham Steffaniak fa8ceb50e6 Fix drag and drop and rows in normal view (#1651)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2025-11-24 12:12:12 -06:00
Graham Steffaniak c8073db4e6 access control download changes (#1653) 2025-11-24 10:02:39 -06:00
Graham Steffaniak 23fac83e7e adjusted list duration 2025-11-24 09:44:40 -06:00
Graham Steffaniak f36ca31c7f updated some build and comments (#1652) 2025-11-24 09:39:21 -06:00
Graham Steffaniak c0466e96fc updated translation for chinese 2025-11-24 09:05:20 -06:00
Graham Steffaniak dc753fe326 consolidate share url creation to backend api (#1647) 2025-11-24 08:18:32 -06:00
Graham Steffaniak c39a1937b4 updated with various bugfixes (#1646) 2025-11-21 19:30:37 -06:00
Graham Steffaniak 5d38b1e66a Download notification (#1645) 2025-11-21 17:31:31 -06:00
Graham Steffaniak e5e93b4b70 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 17:10:18 -06:00
eaedbbe05e Fix user notifications (#1644)
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-21 16:27:58 -06:00
Graham Steffaniak c6d5f32bd0 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 16:23:26 -06:00
Graham Steffaniak ee022bc4a9 Fix only office viewing (#1643) 2025-11-21 15:21:54 -06:00
Kevin B. daabefa6c3 Update spanish translation (#1636) 2025-11-21 15:00:28 -06:00
Graham Steffaniak 86aebde566 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-21 10:59:58 -06:00
Graham Steffaniak a3b0fc0038 Upgrade ffmpeg 8.0.1 (#1634) 2025-11-20 13:50:57 -06:00
dependabot[bot]andGraham Steffaniak 695364c2df Bump golang.org/x/crypto from 0.44.0 to 0.45.0 in /backend in the go_modules group across 1 directory (#1627)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-20 13:36:46 -06:00
Graham Steffaniak 58a01ff986 Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into dev/v1.1.1 2025-11-20 13:36:10 -06:00
Graham Steffaniak 3298111560 Add exif tool (#1625) 2025-11-19 17:54:48 -06:00
Graham Steffaniak 3ca9f54de0 updated with compile safe i18n checks (#1623) 2025-11-19 16:48:44 -06:00
Graham Steffaniak 97d16d1663 add exiftool for orientation 2025-11-19 16:46:47 -06:00
878 changed files with 59513 additions and 133196 deletions

No files matched your search

-39
View File
@@ -1,39 +0,0 @@
{
"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"
]
}
}
}
+1 -8
View File
@@ -1,15 +1,8 @@
backend/**.db
backend/*.sqlite
backend/*.sqlite.*
**/*.sqlite
**/*.sqlite.*
backend/filebrowser.sqlite*
**.crt
**.key
backend/tmp
backend/dist
backend/backend*
backend/internal/web/dist/**
backend/http/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-24.04-arm
runs-on: ubuntu-latest
steps:
- name: Check out repository
+1 -45
View File
@@ -5,13 +5,10 @@ on:
branches:
- "main"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -33,44 +30,3 @@ 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 }}
+8 -14
View File
@@ -11,7 +11,7 @@ on:
jobs:
prime_docker_cache:
name: Prime docker cache
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
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-24.04-arm
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -40,10 +40,10 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: dist
path: backend/internal/web/dist
path: backend/http/dist
build_backend:
name: Build backend
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
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-24.04-arm
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
@@ -80,12 +80,6 @@ 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
@@ -103,8 +97,8 @@ jobs:
run: chmod +x backend/filebrowser
- name: Copy frontend build to backend
run: |
mkdir -p backend/internal/web/embed
cp -r frontend/dist/* backend/internal/web/embed
mkdir -p backend/http/embed
cp -r frontend/dist/* backend/http/embed
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
@@ -124,7 +118,7 @@ jobs:
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
push_pr_to_registry:
name: Build docker image
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
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-24.04-arm
runs-on: ubuntu-latest
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-24.04-arm
runs-on: ubuntu-latest
steps:
- name: Check out repository
+13 -26
View File
@@ -7,25 +7,28 @@ on:
jobs:
test-backend:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go test -race -v ./... -timeout 30s
run: go test -race -v ./...
lint-backend:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go tool golangci-lint run --path-prefix=backend
- uses: golangci/golangci-lint-action@v7
with:
version: 'v2.1.6'
working-directory: backend
install-mode: goinstall
format-backend:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -34,39 +37,23 @@ jobs:
- working-directory: backend
run: go fmt ./...
lint-frontend:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run lint
test-frontend:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run test
check-translations:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
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
run: npm i && npm run i18n:check
+66
View File
@@ -0,0 +1,66 @@
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-24.04-arm
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -36,7 +36,7 @@ jobs:
create_release_tag:
needs: [ test_playwright ]
name: Create Release
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-latest
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-24.04-arm
runs-on: ubuntu-latest
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-24.04-arm
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
@@ -27,7 +27,7 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/gtsteffaniak/playwright-base
tags: chromium
tags: latest
- name: Build and push playwright-base
uses: docker/build-push-action@v6
with:
+2 -10
View File
@@ -1,8 +1,4 @@
*.db
*.db.old
# Playwright settings migration fixture (BoltDB → SQLite tests + Docker settings image)
!_docker/src/settings/backend/database.db.old
*.sqlite**
*.bak
*.log
*.mjs
@@ -22,19 +18,15 @@ 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/internal/web/dist
/backend/internal/web/embed
/backend/http/dist
/backend/http/embed
/backend/debug
.DS_Store
-626
View File
@@ -2,632 +2,6 @@
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)
**New Features**:
- "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)
**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.
- Error downloading zipped directory: no such file or directory (users with scope) #2015
## v1.2.1-stable
**BugFixes**:
- startup icon generation order issue (#2019)
- login button on shares not showing (#2028)
- removed file watcher link in context menu for shares (#2025)
- Direct download link for single-file shares generates incorrect URL (#2026)
- Deleting files causes error V1.2.0 #2039
## v1.2.7-beta
**BugFixes**:
- head > title > infinitely duplicating titles (#2016)
- Proxy auth not working with Nginx, stuck at logo (#2013)
## v1.2.6-beta
**Security**:
- resolves https://github.com/gtsteffaniak/filebrowser/security/advisories/GHSA-8vrh-3pm2-v4v6
## v1.2.5-beta
**Notes**:
- Dependency Updates for frontend and backend packages
- Upgrade to go 1.26
- file list prompt interaction behavior, single-click always selects, double-click navigates. (#1911)
**BugFixes**:
- .ssa and .ass subtitle files get their styling removed during conversion (#1933)
- custom icons issue
- new database message for noauth (#1935)
## v1.2.4-beta
**Notes**:
- Changed `/api/media/subtitles` api endpoint to better support subtitles.
- Correcting some errors in French language (#1947)
- add Dutch `nl.json` for frontend i18n -- thanks @Stephan-P (#1957)
- share banner and icon images automatically serve a scaled down 1024x1024
**BugFixes**:
- Can't access directories with space (and possibly other special characters) when browsing a public link share (#1956)
- External ASS subtitles are not usable in web preview (#1933)
- Filename blocking rename/move (#1950)
- enforcedOtp: true blocks all user settings updates with HTTP 400 (#1962)
- Share favicon URL not showing up and Share description causing endless loading (#1911)
- folder sizes not updated for changes to filesystem outside of filebrowser (#1974)
## v1.2.3-beta
**Notes**:
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
**BugFixes**:
- fix stuck motion preview popup window issue
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
- share delete with user scope issue
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
- delete prompt icons show image previews
## v1.2.2-beta
**New Features**:
- Resizable sidebar (#1896)
- OIDC Authentication: Change Button Text via `frontend.oidcLoginButtonText` (#1708)
- improved favicon processing (#1899)
- supports more formats
- supports larger images
- automatcially generates multiple favicon sizes on startup for non-svg images. Custom svg favicons need a companion *.png to exist broad compatibilty
- Enhanced media playback: Ability to control the queue from your device's lock screen and notification panel - Will also show
metadata of the current playing media if available (#1917)
**Notes**:
- Better text file content detection (#1726)
- More url encoding changes for API which should make things more consistent. Open issues if you see path/source not found errors.
- adjustments to the startup behavior for sqlite index for reusing the previous database on startup
- CTRL + B disables sticky sidebar forever (#1869)
- added context menu back to duplicate finder
- Sharing a link for uploads - folder/file access and UX polishing (#1902)
- improved listing view and scrolling performance
- improved image viewer which will utilize recent thumbnails as a placeholder when loading the full image.
- Small reorganization of "Share" settings to make the popup clear (#1826)
**BugFixes**:
- Added docker default `FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"` back -- mistakenly removed. see https://filebrowserquantum.com/en/docs/getting-started/config/#3-default-locations. (#1891)
- Fix brand text in login screen (#1898)
- Sidebar links cointains urls with 2x repeated source name (#1847)
- 2 factor auth getting overwritten if you edit the auth from admin (#1819)
- New created user's files are listing error, Probably related with language setting. (#1565)
- thumbnail generation cpu/memory and concurrency bug.
- added 75MB filesize limit for image previews
- optimized concurrency for large vs small images
## v1.2.1-beta
**New Features**:
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
**Notes**:
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850)
- updated download api to use repeated `file` query param for path instead of `files` with comma. See swagger for details (#1881)
**BugFixes**:
- OnlyOffice stopped working after 1.2.0 upgrade due to download api changes
- Search result link to wrong path when search context other than / (#1863)
- File actions doesn't work if using user scopes (#1879)
- Drop items into folders doesn't work (#1860)
- duplicate finder context menu (#1862)
- File list in previews doesn't show the parent items (#1861)
## v1.2.0-beta
This is a major version update with many code changes which could cause unexpected behavior. Upgrades should proceed with caution and report any undesriable behavior by opening an issue on github. The index is now fully in sqlite database - see [Indexing Overview](https://filebrowserquantum.com/en/docs/features/indexing/) for more information.
+69 -8
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 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Node.js 20.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/ffprobe**: For media features (subtitles, thumbnails, heic)
- **ffmpeg**: 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,12 +42,11 @@ make dev
### Backend (Go)
- **Entry Point**: `backend/main.go` → `backend/cmd/`
- **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/`
- **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/`
### Frontend (Vue.js + TypeScript)
- **Framework**: Vue 3 + Vite + TypeScript
@@ -91,6 +90,18 @@ 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)
@@ -145,3 +156,53 @@ 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`
+55 -44
View File
@@ -1,30 +1,31 @@
<div align="center">
[![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)
[![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)
[![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" alt="FileBrowser Quantum logo" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174">
<img width="150" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" title="Logo">
<h3>FileBrowser Quantum</h3>
The best free self-hosted web-based file manager.
<br/><br/>
<img width="800" alt="FileBrowser Quantum file listing in dark mode" src="https://filebrowserquantum.com/images/generated/listing/view-mode-normal-dark.jpg">
<img width="800" src="https://github.com/user-attachments/assets/162d7a95-33b7-49bd-976c-dd6822c0d22b">
</div>
## Pinned
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
## 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 and 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 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.
@@ -34,57 +35,67 @@ 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. ✅ 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.
1. ✅ Add and configure multiple sources
2. ✅ Login support for OIDC, password + 2FA, and proxy.
3. ✅ Beautiful, Responsive, and Customizable user interface.
4. ✅ Streamlined configuration via `config.yaml` config file.
5. ✅ Efficient search
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
- 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
- Thumbnails support includes **office**, **video**, and **album artwork**, and **3D models**.
- Better thumbnail support including **office**, **video**, and **album artwork**
- Faster and more responsive views with animations.
- **Folder sizes** are displayed and support thumbnails
- Folder sizes are displayed and support for thumbnails
- Navigating remembers the last scroll position.
- WebDAV support
- Granular permissions
7. ✅ Highly configurable and [customizable sharing options](https://filebrowserquantum.com/en/docs/shares/options/)
7. ✅ Highly configurable and customizable sharing options
- share expiration time
- users who can access share (including anonymous)
- styling and themes
- file viewing, editing, and uploading permissions
8. ✅ Access control that can be scoped to user or group and source path.
8. ✅ Directory-level access control that can be scoped to user or group.
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 / title
3. (Right) The view change toggle / overflow menu
2. (Middle) The powerful search bar.
3. (Right) The view change toggle.
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.
<img width="1200" height="750" alt="Aug-07-2026 14-54-55" src="https://github.com/user-attachments/assets/fd1c1747-f30f-4e21-b991-f45744416d7e" />
<p align="center">
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
</p>
## 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" 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
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
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
@@ -93,45 +104,45 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
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 | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Min. Memory Requirements | 256 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
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 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
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 | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
activity log | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
Can archive selected files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
+16 -15
View File
@@ -1,35 +1,34 @@
FROM gtstef/ffmpeg:9.0-decode AS ffmpeg
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev
RUN go mod download && go run ./scripts/setup-gofitz-cgo
RUN apk update && apk add --no-cache gcc musl-dev upx
ENV CGO_ENABLED=1
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}'" \
ENV GOEXPERIMENT=greenteagc
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}'" \
-o filebrowser .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /build
COPY ./frontend/package.json ./frontend/
WORKDIR /app
COPY ./frontend/package.json ./
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 cd frontend && npm i --maxsockets 1
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm i --maxsockets 1
COPY ./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_PATH="/home/filebrowser/data/database.sqlite"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
@@ -38,11 +37,13 @@ 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 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/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 \
+1 -2
View File
@@ -1,5 +1,4 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps chromium
RUN npx playwright install --with-deps firefox
+5 -7
View File
@@ -9,14 +9,12 @@ 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 ./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 mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser user set testuser1 --password testuser1 && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
-14
View File
@@ -1,14 +0,0 @@
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
@@ -1,22 +0,0 @@
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 ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/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 ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/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 ./internal/web/dist
RUN mkdir -p ./http/dist
COPY [ "./_docker/src/oidc/backend/", "./"]
COPY ["./backend/filebrowser", "./"]
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
WORKDIR /app/frontend
-17
View File
@@ -1,17 +0,0 @@
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 ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/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
@@ -1,16 +0,0 @@
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
+6 -9
View File
@@ -10,16 +10,13 @@ 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 ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
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
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
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
+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 ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
+13 -12
View File
@@ -3,26 +3,26 @@ ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
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}'" \
RUN apk update && apk add upx
ENV GOEXPERIMENT=greenteagc
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}'" \
-o filebrowser .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /build
COPY ./frontend/package.json ./frontend/
WORKDIR /app
COPY ./frontend/package.json ./
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 cd frontend && npm i
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm i
COPY ./frontend/ ./
RUN npm run build:docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN adduser -D -s /bin/true -u 1000 filebrowser
@@ -32,9 +32,10 @@ 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 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/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 \
+1 -35
View File
@@ -1,19 +1,4 @@
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
@@ -22,30 +7,11 @@ 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/data/config.yaml"
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile.slim
+3 -7
View File
@@ -1,7 +1,5 @@
server:
<<: *server-main
http:
<<: *http-main
frontend:
<<: *frontend-main
@@ -10,10 +8,8 @@ userDefaults:
image: true
popup: true
video: true
audio: true
models: true
office: true
highQuality: true
account:
permissions:
share: true
permissions:
download: true
create: true
+4 -13
View File
@@ -1,24 +1,15 @@
http-main: &http-main
server-main: &server-main
port: 80
baseURL: "/"
server-main: &server-main
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
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:
defaultEnabled: true
rules:
- folderPath: "/"
viewable: true
disableIndexing: true
defaultEnabled: true
-53
View File
@@ -1,53 +0,0 @@
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
@@ -1,66 +0,0 @@
# 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
@@ -1,101 +0,0 @@
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
@@ -1,3 +0,0 @@
module test
go 1.25.6
@@ -1,66 +0,0 @@
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
},
},
],
});
+17 -19
View File
@@ -1,20 +1,13 @@
http:
server:
port: 80
baseURL: "/files/"
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
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"
@@ -34,13 +27,16 @@ server:
- filePath: "/subfolderExclusions/exclusionlist.sh"
- folderPath: "/subfolderExclusions/folderItem"
viewable: true
- fileStartsWith: "startsWith"
- folderEndsWith: "Folder"
- folderName: "folderName"
- fileName: "fileName"
- fileName: "fileNames"
- folderName: ".hiddenDir"
- fileName: ".hidden"
conditionals:
rules:
- fileStartsWith: "startsWith"
- folderEndsWith: "Folder"
- folderNames: 'folderNames'
- fileNames: 'fileNames'
- folderName: 'folderName'
- fileName: 'fileName'
- folderName: '.hiddenDir'
- fileName: '.hidden'
auth:
methods:
@@ -57,11 +53,13 @@ frontend:
disableEventThemes: true
userDefaults:
listing:
showHidden: true
showHidden: true
preview:
image: true
popup: true
video: true
office: true
highQuality: true
highQuality: true
permissions:
download: true
create: true
+10 -16
View File
@@ -1,17 +1,11 @@
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"
@@ -41,13 +35,13 @@ userDefaults:
video: true
office: true
highQuality: true
ui:
darkMode: true
listing:
singleClick: false
account:
disableSettings: false
permissions:
admin: false
share: false
api: false
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: false
api: false
download: true
create: true
-41
View File
@@ -1,41 +0,0 @@
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
@@ -1,54 +0,0 @@
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"] },
},
],
});
+19 -14
View File
@@ -1,21 +1,13 @@
http:
port: 8080
baseURL: "/subpath"
trustProxyHeaders: true
server:
port: 8080
baseURL: "/subpath"
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
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"
@@ -37,6 +29,19 @@ auth:
logoutRedirectUrl: "https://localhost/api/auth/logout2"
userDefaults:
account:
permissions:
share: true
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
@@ -11,14 +11,14 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/proxy-setup.ts",
timeout: 10000,
timeout: 5000,
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: 3,
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 */
@@ -1,12 +0,0 @@
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
@@ -1,65 +0,0 @@
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',
},
},
],
});
+4 -16
View File
@@ -1,26 +1,18 @@
http:
server:
port: 80
baseURL: "/"
server:
database:
migrateFrom: "database.db.old"
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
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:
@@ -43,8 +35,4 @@ frontend:
auth:
methods:
password:
signup: true
userDefaults:
listing:
showHidden: true
signup: true
Binary file not shown.
@@ -11,7 +11,7 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/global-setup",
timeout: 5000,
timeout: 3000,
testDir: "./tests/playwright/settings",
/* Run tests in files in parallel */
fullyParallel: false,
+2 -4
View File
@@ -1,10 +1,8 @@
http:
server:
port: 80
baseURL: "/"
server:
logging:
- levels: "info|error|warning|debug"
apiFilter: 'disabled'
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
+3 -4
View File
@@ -2,12 +2,11 @@ root = "."
tmp_dir = "tmp"
[build]
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"
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"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs", "preview/thumbnails", "preview/heic"]
log = "air.log"
delay = 1000 # ms
+4 -5
View File
@@ -2,12 +2,11 @@ root = "."
tmp_dir = "tmp"
[build]
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"
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"]
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "internal\\web\\dist", "internal\\web\\embed", "internal\\preview\\testdata", "internal\\preview\\thumbnails", "internal\\preview\\heic", "swagger\\docs"]
exclude_dir = ["tmp", "vendor", "http\\dist", "http\\embed", "swagger\\docs"]
exclude_unchanged = true
follow_symlink = false
poll = true
+3 -18
View File
@@ -2,11 +2,6 @@
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
@@ -14,11 +9,10 @@ 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/internal/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA={{ .ShortCommit }}
- -s -w -X github.com/gtsteffaniak/filebrowser/backend/common/version.Version=v{{ .Version }} -X github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA={{ .ShortCommit }}
main: main.go
binary: filebrowser
goos:
@@ -38,7 +32,6 @@ builds:
- CGO_ENABLED=1
- CC=zig cc -target aarch64-linux-musl
tags:
- analytics
- mupdf
- musl
goos:
@@ -54,8 +47,6 @@ builds:
ldflags: *ldflags
main: main.go
binary: filebrowser
tags:
- analytics
goos:
- linux
goarch:
@@ -71,10 +62,8 @@ builds:
- id: windows
env:
- CGO_ENABLED=1
# windows-gnu avoids zig rejecting Go's -tsaware when cross-compiling from macOS
- CC=zig cc -target x86_64-windows-gnu -lc
- CC=zig cc -target x86_64-windows
tags:
- analytics
- mupdf
ldflags: *ldflags
main: main.go
@@ -92,20 +81,16 @@ builds:
ldflags: *ldflags
main: main.go
binary: filebrowser
tags:
- analytics
goos:
- freebsd
goarch:
- amd64
# Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported)
# Build configuration for macos without upx
- id: macos
ldflags: *ldflags
main: main.go
binary: filebrowser
tags:
- analytics
goos:
- darwin
goarch:
@@ -10,9 +10,6 @@ import (
"os"
"path/filepath"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Cache interface for caching operations
@@ -32,61 +29,9 @@ type FileCache struct {
}
}
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
cacheDir := filepath.Join(dir, "diskcache")
// Migrate existing cache files from old structure (if any)
if err := migrateOldCacheStructure(dir, cacheDir); err != nil {
logger.Warningf("failed to migrate old cache structure: %v\n", err)
}
// Create the cache directory immediately with proper permissions
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
return &FileCache{dir: cacheDir}, nil
}
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
func migrateOldCacheStructure(oldDir, newDir string) error {
// Read the old cache directory
entries, err := os.ReadDir(oldDir)
if err != nil {
if os.IsNotExist(err) {
return nil // Directory doesn't exist yet, nothing to migrate
}
return err
}
migrated := 0
for _, entry := range entries {
// Only migrate single-character directories (cache hash directories like a, b, c, etc.)
if !entry.IsDir() || len(entry.Name()) != 1 {
continue
}
// Skip if it's already the diskcache directory
if entry.Name() == "d" && filepath.Join(oldDir, entry.Name()) == newDir {
continue
}
oldPath := filepath.Join(oldDir, entry.Name())
newPath := filepath.Join(newDir, entry.Name())
// Create parent directory in new location
if err := os.MkdirAll(newDir, fileutils.PermDir); err != nil {
return err
}
// Move the directory
if err := os.Rename(oldPath, newPath); err != nil {
// If rename fails (e.g., cross-device), skip this one
continue
}
migrated++
}
return nil
return &FileCache{dir: dir}, nil
}
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
@@ -95,11 +40,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
defer mu.Unlock()
fileName := f.getFileName(key)
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
return err
}
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
if err := os.WriteFile(fileName, value, 0600); err != nil {
return err
}
@@ -6,7 +6,6 @@ import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/stretchr/testify/require"
)
@@ -20,9 +19,6 @@ func TestFileCache(t *testing.T) {
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
)
// Set up file permissions before creating cache
fileutils.SetFsPermissions(0644, 0755)
// Create temporary directory for the cache
cacheDir, err := os.MkdirTemp("", cacheRoot)
require.NoError(t, err)
@@ -32,20 +28,19 @@ func TestFileCache(t *testing.T) {
require.NoError(t, err)
// store new key
// Note: NewFileCache creates a "diskcache" subdirectory, so the actual path includes it
err = cache.Store(ctx, key, []byte(value))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
// update existing key
err = cache.Store(ctx, key, []byte(newValue))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
// delete key
err = cache.Delete(ctx, key)
require.NoError(t, err)
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
require.False(t, exists)
}
+411
View File
@@ -0,0 +1,411 @@
package files
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func Test_GetRealPath(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
return
}
trimPrefix := filepath.Dir(filepath.Dir(cwd))
tests := []struct {
name string
paths []string
want struct {
path string
isDir bool
}
}{
{
name: "current directory",
paths: []string{
"./",
},
want: struct {
path string
isDir bool
}{
path: "",
isDir: true,
},
},
{
name: "current directory",
paths: []string{
"./files/file.go",
},
want: struct {
path string
isDir bool
}{
path: "/files/file.go",
isDir: false,
},
},
{
name: "other test case",
paths: []string{
"/mnt/doesnt/exist",
},
want: struct {
path string
isDir bool
}{
path: "/mnt/doesnt/exist",
isDir: false,
},
},
}
idx := indexing.Index{
Source: settings.Source{
Path: trimPrefix,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
realPath, isDir, _ := idx.GetRealPath(tt.paths...)
adjustedRealPath := strings.TrimPrefix(realPath, trimPrefix)
if tt.want.path != adjustedRealPath || tt.want.isDir != isDir {
t.Errorf("expected %v:%v but got: %v:%v", tt.want.path, tt.want.isDir, adjustedRealPath, isDir)
}
})
}
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
input iteminfo.FileInfo
expected iteminfo.FileInfo
}{
{
name: "Numeric and Lexicographical Sorting",
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "10.txt"},
{Name: "2.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
},
},
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "2.txt"},
{Name: "10.txt"},
{Name: "apple"},
{Name: "Banana"},
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
},
},
},
{
name: "Only Lexicographical Sorting",
input: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "dog.txt"},
{Name: "Cat.txt"},
{Name: "apple"},
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
},
},
expected: iteminfo.FileInfo{
Folders: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "Cat.txt"},
{Name: "dog.txt"},
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.input.SortItems()
getFolderNames := func(items []iteminfo.ItemInfo) []string {
names := []string{}
for _, folder := range items {
names = append(names, folder.Name)
}
return names
}
getFileNames := func(items []iteminfo.ExtendedItemInfo) []string {
names := []string{}
for _, file := range items {
names = append(names, file.Name)
}
return names
}
actualFolderNames := getFolderNames(test.input.Folders)
expectedFolderNames := getFolderNames(test.expected.Folders)
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
}
actualFileNames := getFileNames(test.input.Files)
expectedFileNames := getFileNames(test.expected.Files)
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
}
})
}
}
// TestDeleteFilesCacheClearing was removed because cache clearing is not performed
// per design decision - RealPathCache is auxiliary and doesn't need clearing
func TestOverrideDirectoryToFile(t *testing.T) {
// Use a temporary directory for cache to avoid creating directories in the source tree
tmpDir := t.TempDir()
originalCacheDir := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = tmpDir
defer func() {
settings.Config.Server.CacheDir = originalCacheDir
}()
// Ensure fileutils permissions are set (needed by NewTempDB)
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0644, 0755)
}
// Initialize the database first (use test helper to avoid permission issues)
if indexing.GetIndexDB() == nil {
db, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
// Initialize the index with scanning disabled to prevent background scanner interference
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
// Get the index and set up mock data
idx := indexing.GetIndex("test")
if idx == nil { //nolint:staticcheck // t.Fatal terminates execution
t.Fatal("Failed to get test index")
}
// Wait for initial scanner to complete (it will fail on /mock/path but needs to finish cleanup)
// This ensures batchItems is nil before we start testing
for i := 0; i < 50; i++ { // Wait up to 500ms
time.Sleep(10 * time.Millisecond)
status := idx.GetScannerStatus()
if status["status"] == "ready" || status["status"] == "unavailable" {
break
}
}
// Create mock directory structure using UpdateMetadata
rootInfo := &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
},
Folders: []iteminfo.ItemInfo{
{Name: "Test Object", Type: "directory"},
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
// Delete the old directory item from the database before replacing it with a file
idx.DeleteMetadata("/Test Object", true, false)
// Simulate the directory-to-file override by updating the mock data
// Remove the directory from the parent's Folders slice
rootInfo.Folders = []iteminfo.ItemInfo{} // Clear folders
// Add the file to the parent's Files slice
rootInfo.Files = []iteminfo.ExtendedItemInfo{
{
ItemInfo: iteminfo.ItemInfo{
Name: "Test Object",
Size: 25, // Length of "This is test file content"
},
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
// Verify the directory was replaced with a file in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
if !exists {
t.Fatal("Root metadata not found")
}
// Check that the directory was removed from Folders
foundDir := false
for _, folder := range rootInfo.Folders {
if folder.Name == "Test Object" {
foundDir = true
break
}
}
if foundDir {
t.Error("Directory 'Test Object' should have been removed from Folders")
}
// Check that the file was added to Files
foundFile := false
for _, file := range rootInfo.Files {
if file.Name == "Test Object" {
foundFile = true
if file.Size != 25 {
t.Errorf("Expected file size 25, got %d", file.Size)
}
break
}
}
if !foundFile {
t.Error("File 'Test Object' should have been added to Files")
}
}
func TestOverrideFileToDirectory(t *testing.T) {
// Use a temporary directory for cache to avoid creating directories in the source tree
tmpDir := t.TempDir()
originalCacheDir := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = tmpDir
defer func() {
settings.Config.Server.CacheDir = originalCacheDir
}()
// Ensure fileutils permissions are set (needed by NewTempDB)
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0644, 0755)
}
// Initialize the database first (use test helper to avoid permission issues)
if indexing.GetIndexDB() == nil {
db, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
// Initialize the index in mock mode (no filesystem operations)
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
// Get the index and set up mock data
idx := indexing.GetIndex("test")
if idx == nil { //nolint:staticcheck // t.Fatal terminates execution
t.Fatal("Failed to get test index")
}
// Wait for initial scanner to complete (it will fail on /mock/path but needs to finish cleanup)
// This ensures batchItems is nil before we start testing
for i := 0; i < 50; i++ { // Wait up to 500ms
time.Sleep(10 * time.Millisecond)
status := idx.GetScannerStatus()
if status["status"] == "ready" || status["status"] == "unavailable" {
break
}
}
// Create mock directory structure with a file using UpdateMetadata
rootInfo := &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
// Delete the old file item from the database before replacing it with a directory
idx.DeleteMetadata("/Test Object", false, false)
// Simulate the file-to-directory override by updating the mock data
// Remove the file from the parent's Files slice
rootInfo.Files = []iteminfo.ExtendedItemInfo{} // Clear files
// Add the directory to the parent's Folders slice
rootInfo.Folders = []iteminfo.ItemInfo{
{
Name: "Test Object",
Type: "directory",
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
// Verify the file was replaced with a directory in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
if !exists {
t.Fatal("Root metadata not found")
}
// Check that the file was removed from Files
foundFile := false
for _, file := range rootInfo.Files {
if file.Name == "Test Object" {
foundFile = true
break
}
}
if foundFile {
t.Error("File 'Test Object' should have been removed from Files")
}
// Check that the directory was added to Folders
foundDir := false
for _, folder := range rootInfo.Folders {
if folder.Name == "Test Object" {
foundDir = true
if folder.Type != "directory" {
t.Errorf("Expected directory type, got %s", folder.Type)
}
break
}
}
if !foundDir {
t.Error("Directory 'Test Object' should have been added to Folders")
}
}
File diff suppressed because it is too large. Load diff
@@ -108,6 +108,7 @@ var types = map[string]string{
".chrt": "application/vnd.kde.kchart",
".class": "application/java",
".com": ContentTextHeaderValue,
".conf": ContentTextHeaderValue,
".cpio": "application/x-cpio",
".cpp": "text/x-c",
".cpt": "application/mac-compactpro",
@@ -148,11 +149,7 @@ var types = map[string]string{
".es": "application/x-esrehber",
".etx": "text/x-setext",
".evy": "application/envoy",
".exe": "application/x-msdownload",
".dll": "application/x-msdownload",
".bat": "application/x-msdos-program",
".cmd": "application/x-msdos-program",
".msi": "application/x-msdownload",
".exe": "application/octet-stream",
".f77": "text/x-fortran",
".f90": "text/x-fortran",
".f": "text/x-fortran",
@@ -228,6 +225,7 @@ var types = map[string]string{
".js": ContentJavascriptHeaderValue,
".mjs": ContentJavascriptHeaderValue,
".json": ContentJSONHeaderValue,
".vue": ContentJavascriptHeaderValue,
".jut": "image/jutvision",
".kar": "audio/midi",
".karbon": "application/vnd.kde.karbon",
@@ -245,8 +243,8 @@ var types = map[string]string{
".la": "audio/nspaudio",
".lam": "audio/x-liveaudio",
".latex": "application/x-latex",
".lha": "application/x-lzh-compressed",
".lhx": "application/x-lzh-compressed",
".lha": "application/lha",
".lhx": "application/octet-stream",
".list": ContentTextHeaderValue,
".lma": "audio/nspaudio",
".log": ContentTextHeaderValue,
@@ -259,6 +257,7 @@ var types = map[string]string{
".m1v": "video/mpeg",
".m2a": "audio/mpeg",
".m2v": "video/mpeg",
".m3u": "audio/x-mpegurl",
".m": "text/x-m",
".man": "application/x-troff-man",
".manifest": "text/cache-manifest",
@@ -308,6 +307,7 @@ var types = map[string]string{
".ncm": "application/vndnokiaconfiguration-message",
".nif": "image/x-niff",
".niff": "image/x-niff",
".nix": "application/x-mix-transfer",
".nsc": "application/x-conference",
".nvd": "application/x-navidoc",
".o": "application/octet-stream",
@@ -343,14 +343,15 @@ var types = map[string]string{
".p7m": "application/pkcs7-mime",
".p7r": "application/x-pkcs7-certreqresp",
".p7s": "application/pkcs7-signature",
".p": "text/x-pascal",
".part": "application/pro_eng",
".pas": "text/pascal",
".pbm": "image/x-portable-bitmap",
".pcl": "application/vndhp-pcl",
".pct": "image/x-pict",
".pcx": "image/x-pcx",
".pdb": "chemical/x-pdb",
".pdf": "application/pdf",
".pdfa": "application/pdf",
".pfunk": "audio/make",
".pgm": "image/x-portable-graymap",
".pic": "image/pict",
@@ -374,7 +375,7 @@ var types = map[string]string{
".pre": "application/x-freelance",
".prt": "application/pro_eng",
".ps": "application/postscript",
".psd": "image/vnd.adobe.photoshop",
".psd": "application/octet-stream",
".pvu": "paleovu/x-pv",
".pwz": "application/vndms-powerpoint",
".py": "text/x-scriptphyton",
@@ -442,12 +443,9 @@ var types = map[string]string{
".sprite": "application/x-sprite",
".spx": "audio/ogg",
".src": "application/x-wais-source",
".srt": "text/subtitle-srt",
".ass": "text/subtitle-ass",
".vtt": "text/subtitle-vtt",
".ssa": "text/subtitle-ssa",
".lrc": "text/lyrics",
".srt": "text/plain",
".sbv": "text/plain",
".ssa": "text/plain",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
".sst": "application/vndms-pkicertstore",
@@ -599,105 +597,11 @@ var types = map[string]string{
".ogx": "application/ogg",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".webm": "video/webm",
".weba": "audio/webm",
".caf": "audio/x-caf",
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
".m3u8": "text/x-mpegURL",
".pls": "text/x-scpls",
".xspf": "application/xspf+xml",
".m3u8": "application/x-mpegURL",
".mpd": "application/dash+xml",
".webp": "image/webp",
".epub": "application/epub+zip",
".db": "application/x-db",
".db2": "application/x-db",
".db3": "application/x-db",
".sql": "application/sql",
".sqlite": "application/vnd.sqlite3",
".sqlite3": "application/vnd.sqlite3",
".yaml": "application/yaml",
".app": "application/x-application",
".geojson": "application/geo+json",
".sketch": "application/x-sketch",
".bak": "application/backup",
".backup": "application/backup",
".xfc": "application/x-xcf",
".vue": "text/vue",
".conf": "text/config-file",
".ini": "text/ini",
".ps1": "application/x-powershell",
".ps2": "application/x-powershell",
".ps3": "application/x-powershell",
".dart": "application/vnd.dart",
".flutter": "text/flutter",
".tsx": "text/x-typescript",
".ts": "text/x-typescript",
".php": "application/x-php",
".php2": "application/x-php",
".php3": "application/x-php",
".phtml": "application/x-php",
".p": "text/x-pascal",
".pas": "text/x-pascal",
".zig": "text/x-zig",
".ex": "text/x-elixir",
".exs": "text/x-elixir",
".json5": "application/json5",
".pmx": "model/x-pmx",
".nix": "text/nix-lang",
".cjs": "text/javascript",
".torrent": "application/x-bittorrent",
".tmp": "text/tmp",
".temp": "text/tmp",
// the ones below are some from: https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
".adp": "audio/adpcm",
".mp4a": "audio/mp4",
".mp2a": "audio/mpeg",
".m3a": "audio/mpeg",
".sil": "audio/silk",
".uva": "audio/vnd.dece.audio",
".uvva": "audio/vnd.dece.audio",
".eol": "audio/vnd.digital-winds",
".dra": "audio/vnd.dra",
".dts": "audio/vnd.dts",
".dtshd": "audio/vnd.dts.hd",
".lvp": "audio/vnd.lucent.voice",
".pya": "audio/vnd.ms-playready.media.pya",
".rip": "audio/vnd.rip",
".mka": "audio/x-matroska",
".h261": "video/h261",
".h263": "video/h263",
".h264": "video/h264",
".jpgv": "video/jpeg",
".jpm": "video/jpm",
".jpgm": "video/jpm",
".mj2": "video/mj2",
".mjp2": "video/mj2",
".m2t": "video/mp2t",
".m2ts": "video/mp2t",
".mts": "video/mp2t",
".mp4v": "video/mp4",
".mpg4": "video/mp4",
".uvh": "video/vnd.dece.hd",
".uvvh": "video/vnd.dece.hd",
".uvm": "video/vnd.dece.mobile",
".uvvm": "video/vnd.dece.mobile",
".uvp": "video/vnd.dece.pd",
".uvvp": "video/vnd.dece.pd",
".uvs": "video/vnd.dece.sd",
".uvvs": "video/vnd.dece.sd",
".uvv": "video/vnd.dece.video",
".uvvv": "video/vnd.dece.video",
".dvb": "video/vnd.dvb.file",
".fvt": "video/vnd.fvt",
".mxu": "video/vnd.mpegurl",
".pyv": "video/vnd.ms-playready.media.pyv",
".uvu": "video/vnd.uvvu.mp4",
".uvvu": "video/vnd.uvvu.mp4",
".f4v": "video/x-f4v",
".mk3d": "video/x-matroska",
".mks": "video/x-matroska",
".vob": "video/x-ms-vob",
".smv": "video/x-smv",
}
//nolint:gochecknoinits
+75
View File
@@ -0,0 +1,75 @@
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
}
@@ -53,10 +53,6 @@ func CopyDir(source, dest string) error {
}
}
}
// Preserve directory mod times
if err := os.Chtimes(dest, srcinfo.ModTime(), srcinfo.ModTime()); err != nil {
errs = append(errs, err)
}
var errString string
for _, err := range errs {
@@ -12,45 +12,9 @@ import (
var PermFile os.FileMode
var PermDir os.FileMode
// SetFsPermissions sets create modes from Unix chmod(2)-style values (for example
// values produced by strconv.ParseUint(s, 8, 32)). setuid, setgid, and sticky
// bits must be converted for Go's os.FileMode; a plain cast from Unix octal is
// incorrect for those bits.
func SetFsPermissions(unixFileMode, unixDirMode uint32) {
PermFile = unixModeToFileMode(unixFileMode)
PermDir = unixModeToFileMode(unixDirMode)
}
// EffectiveDirPerm returns [PermDir] if [SetFsPermissions] was called, otherwise 0o755. On Unix,
// [os.Mkdir] with perm 0 fails with permission denied, so use this (or [PermFile]) for creates when
// the process has not set globals yet.
func EffectiveDirPerm() os.FileMode {
if PermDir == 0 {
return 0o755
}
return PermDir
}
// EffectiveFilePerm returns [PermFile] if set, otherwise 0o644.
func EffectiveFilePerm() os.FileMode {
if PermFile == 0 {
return 0o644
}
return PermFile
}
func unixModeToFileMode(u uint32) os.FileMode {
m := os.FileMode(u & 0777)
if u&0o4000 != 0 {
m |= os.ModeSetuid
}
if u&0o2000 != 0 {
m |= os.ModeSetgid
}
if u&0o1000 != 0 {
m |= os.ModeSticky
}
return m
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
PermFile = PermFileOctal
PermDir = PermDirOctal
}
// MoveFile moves a file from src to dst.
@@ -113,7 +77,7 @@ func copySingleFile(source, dest string) error {
defer src.Close()
// Create the destination directory if needed.
err = os.MkdirAll(filepath.Dir(dest), EffectiveDirPerm())
err = os.MkdirAll(filepath.Dir(dest), PermDir)
if err != nil {
return err
}
@@ -139,21 +103,14 @@ func copySingleFile(source, dest string) error {
// The file was copied successfully, so we continue
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
}
// Preserve source file mod time
if err := os.Chtimes(dest, srcInfo.ModTime(), srcInfo.ModTime()); err != nil {
logger.Debugf("Could not preserve modification time for %s: %v", dest, err)
}
return nil
}
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
srcInfo, err := os.Stat(source)
if err != nil {
return err
}
// Create the destination directory.
err = os.MkdirAll(dest, EffectiveDirPerm())
err := os.MkdirAll(dest, PermDir)
if err != nil {
return err
}
@@ -183,40 +140,8 @@ func copyDirectory(source, dest string) error {
}
}
}
return os.Chtimes(dest, srcInfo.ModTime(), srcInfo.ModTime())
}
// PreserveModTimes copies the mod times from src onto dst, this is used by the webdav COPY handler
func PreserveModTimes(src, dst string) error {
info, err := os.Lstat(src)
if err != nil {
return err
}
if !info.IsDir() {
return os.Chtimes(dst, info.ModTime(), info.ModTime())
}
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
if err != nil {
logger.Debugf("Error accessing %s to set mod time: %v", p, err)
return nil
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(src, p)
if err != nil {
return nil
}
info, err := d.Info()
if err != nil {
logger.Debugf("Error getting info for %s: %v", p, err)
return nil
}
if err := os.Chtimes(filepath.Join(dst, rel), info.ModTime(), info.ModTime()); err != nil {
logger.Debugf("Could not preserve modification time for %s: %v", filepath.Join(dst, rel), err)
}
return nil
})
return nil
}
// CommonPrefix returns the common directory path of provided files.
@@ -279,22 +204,3 @@ func ClearCacheDir(cacheDir string) {
}
}
// ClearDirectoryContents removes all files and subdirectories inside dir; dir itself remains.
// If dir does not exist, returns nil.
func ClearDirectoryContents(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if err := os.RemoveAll(path); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,46 @@
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)
}
})
}
}
@@ -29,16 +29,3 @@ func GetFreeSpace(path string) (uint64, error) {
free := uint64(stat.Bavail) * uint64(stat.Bsize)
return free, nil
}
// GetPartitionUsed returns the used space for Unix systems (total - free)
func GetPartitionUsed(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
total := uint64(stat.Blocks) * uint64(stat.Bsize)
free := uint64(stat.Bavail) * uint64(stat.Bsize)
used := total - free
return used, nil
}
@@ -33,18 +33,3 @@ func GetFreeSpace(path string) (uint64, error) {
}
return freeBytes, nil
}
// GetPartitionUsed returns the used space for Windows systems (total - free)
func GetPartitionUsed(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
used := totalBytes - freeBytes
return used, nil
}
+34
View File
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,114 @@
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
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,28 @@
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
}
@@ -5,8 +5,8 @@ import (
"crypto/sha256"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/crypto/pbkdf2"
)
@@ -23,8 +23,21 @@ type Storage struct {
users *users.Storage
}
// InitializeEncryption initializes the encryption key for TOTP from config
func InitializeEncryption() error {
// NewStorage creates a auth storage from a backend.
func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error) {
store := &Storage{back: back, users: userStore}
err := store.Save(&JSONAuth{})
if err != nil {
return nil, err
}
err = store.Save(&ProxyAuth{})
if err != nil {
return nil, err
}
err = store.Save(&NoAuth{})
if err != nil {
return nil, err
}
if settings.Config.Auth.TotpSecret != "" {
key, err := base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
keyLen := len(key)
@@ -39,31 +52,6 @@ func InitializeEncryption() error {
encryptionKey = pbkdf2.Key([]byte(settings.Config.Auth.TotpSecret), salt, 4096, 32, sha256.New)
}
}
return nil
}
// NewStorage creates a auth storage from a backend.
func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error) {
store := &Storage{back: back, users: userStore}
err := store.Save(&JSONAuth{DisableOtp: true})
if err != nil {
return nil, err
}
err = store.Save(&ProxyAuth{})
if err != nil {
return nil, err
}
err = store.Save(&NoAuth{})
if err != nil {
return nil, err
}
// Initialize encryption key
err = InitializeEncryption()
if err != nil {
return nil, err
}
return store, nil
}
@@ -10,8 +10,8 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/pquerna/otp"
@@ -51,7 +51,7 @@ var (
TotpCache = cache.NewCache[string](5 * time.Minute)
)
func GenerateOtpForUser(user *users.User) (string, error) {
func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, error) {
// Generate a new TOTP key using the defined constants.
key, err := totp.Generate(totp.GenerateOpts{
Issuer: IssuerName,
@@ -67,17 +67,15 @@ func GenerateOtpForUser(user *users.User) (string, error) {
secretText := key.Secret()
nonce := ""
secretToStore := secretText
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is provided, encrypt the secret.
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
if err != nil {
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
}
}
// set cache so verify can attempt to use it but not require it for user yet.
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
// Use the original base32 secret in the OTP URL, not the encrypted version
TotpCache.Set(user.Username, secretText+"||"+nonce)
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
return url, nil
}
@@ -145,15 +143,15 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
return string(plaintext), nil
}
func VerifyTotpCode(user *users.User, code string) error {
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
// get data from cache
cachedSecret, found := TotpCache.Get(user.Username)
if !found && user.TOTPSecret == "" {
return fmt.Errorf("OTP token not found in cache, please generate a new one")
}
useCache := found
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
if useCache {
if found {
splitSecret := strings.Split(cachedSecret, "||")
if len(splitSecret) < 2 {
return fmt.Errorf("invalid cached OTP token format")
@@ -173,7 +171,9 @@ func VerifyTotpCode(user *users.User, code string) error {
}
secretToValidate = decryptedSecret
}
// --- END: ADD THIS DECRYPTION LOGIC ---
// Validate the token using the (now plaintext) secret.
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
Period: TOTPPeriod,
Skew: TOTPSkew,
@@ -181,17 +181,20 @@ func VerifyTotpCode(user *users.User, code string) error {
Algorithm: TOTPAlgorithm,
})
if err != nil {
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
logger.Errorf("error during TOTP validation: %v", err)
}
if !valid {
logger.Warningf("Invalid TOTP code for user %s", user.Username)
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" || !user.OtpEnabled {
user.TOTPSecret = totpSecret
user.TOTPNonce = totpNonce
user.OtpEnabled = true
TotpCache.Delete(user.Username)
if totpSecret != "" {
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
user.OtpEnabled = true // Enable OTP for the user
// save user
if err := userStore.Update(user, true, "TOTPSecret", "TOTPNonce", "OtpEnabled"); err != nil {
logger.Error("error updating user with OTP token:", err)
return fmt.Errorf("error updating user with OTP token: %w", err)
}
} else {
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
}
+41 -14
View File
@@ -4,23 +4,27 @@ import (
"fmt"
"strings"
"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/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"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 {
rules, err := state.GetAllRules(sourcePath)
// Get all rules for this source
rules, err := store.Access.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, "/") {
@@ -35,55 +39,78 @@ 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 + "/"
if err := migrateAccessRule(sourcePath, newPath, rule); err != nil {
// Migrate the rule to the new path
if err := migrateAccessRule(sourcePath, oldPath, newPath, rule); err != nil {
logger.Errorf("Failed to migrate rule from %s to %s: %v", oldPath, newPath, err)
continue
}
state.RemoveRuleByPathKey(sourcePath, oldPath)
// Remove the old rule
if err := removeOldAccessRule(sourcePath, oldPath); err != nil {
logger.Errorf("Failed to remove old rule %s: %v", oldPath, err)
continue
}
migratedCount++
}
}
// After migration, clear cache
if migratedCount > 0 {
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
}
}
}
func migrateAccessRule(sourcePath, newPath string, rule access.FrontendAccessRule) error {
// migrateAccessRule creates a new access rule with the new path format
func migrateAccessRule(sourcePath, oldPath, newPath string, rule access.FrontendAccessRule) error {
// Add deny users
for _, user := range rule.Deny.Users {
if err := state.DenyUser(sourcePath, utils.IndexPathFromNormalized(newPath, true), user); err != nil && err != errors.ErrExist {
if err := store.Access.DenyUser(sourcePath, newPath, 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 := state.DenyGroup(sourcePath, utils.IndexPathFromNormalized(newPath, true), group); err != nil && err != errors.ErrExist {
if err := store.Access.DenyGroup(sourcePath, newPath, 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 := state.AllowUser(sourcePath, utils.IndexPathFromNormalized(newPath, true), user); err != nil && err != errors.ErrExist {
if err := store.Access.AllowUser(sourcePath, newPath, 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 := state.AllowGroup(sourcePath, utils.IndexPathFromNormalized(newPath, true), group); err != nil && err != errors.ErrExist {
if err := store.Access.AllowGroup(sourcePath, newPath, 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 := state.DenyAll(sourcePath, utils.IndexPathFromNormalized(newPath, true)); err != nil && err != errors.ErrExist {
if err := store.Access.DenyAll(sourcePath, newPath); 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
@@ -1,224 +0,0 @@
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)
}
}
+258 -96
View File
@@ -2,6 +2,7 @@ package cmd
import (
"bufio"
"flag"
"fmt"
"os"
"path/filepath"
@@ -9,12 +10,11 @@ import (
"strings"
"github.com/goccy/go-yaml"
"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/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/go-logger/logger"
)
@@ -22,44 +22,147 @@ var (
configPath string
)
func setRule(backendSourcePath, indexPath, ruleCategory, value string, allow bool) error {
if backendSourcePath == "" {
return fmt.Errorf("backend source path is missing; check --source / -s")
// 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]
// 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 "-u":
err := setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
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>")
}
if indexPath == "" {
return fmt.Errorf("--path / -p is required (index path within the source, e.g. /)")
return fmt.Errorf("index path is required: use -p <indexPath>")
}
if ruleCategory == "" {
return fmt.Errorf("role is required: use --role / -r <user|group|all>")
return fmt.Errorf("ruleCategory is required: use -r <user|group|all>")
}
if ruleCategory != "all" && value == "" {
return fmt.Errorf("value is required when role is 'user' or 'group': use --value / -v <username|groupname>")
return fmt.Errorf("value is required when ruleCategory is 'user' or 'group': use -v <username|groupname>")
}
parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true)
if err != nil {
return err
}
// Initialize store and settings
_ = getStore(dbConfig) // ignore bool check
// Apply the rule based on allow flag and ruleCategory
var err error
if allow {
switch ruleCategory {
case "user":
err = state.AllowUser(backendSourcePath, parsedPath, value)
err = store.Access.AllowUser(fsPath, indexPath, value)
case "group":
err = state.AllowGroup(backendSourcePath, parsedPath, value)
err = store.Access.AllowGroup(fsPath, indexPath, value)
default:
return fmt.Errorf("invalid role for allow: must be 'user' or 'group'")
return fmt.Errorf("invalid ruleCategory for allow: must be 'user' or 'group'")
}
} else {
switch ruleCategory {
case "user":
err = state.DenyUser(backendSourcePath, parsedPath, value)
err = store.Access.DenyUser(fsPath, indexPath, value)
case "group":
err = state.DenyGroup(backendSourcePath, parsedPath, value)
err = store.Access.DenyGroup(fsPath, indexPath, value)
case "all":
err = state.DenyAll(backendSourcePath, parsedPath)
err = store.Access.DenyAll(fsPath, indexPath)
default:
return fmt.Errorf("invalid role: must be 'user', 'group', or 'all'")
return fmt.Errorf("invalid ruleCategory: must be 'user', 'group', or 'all'")
}
}
@@ -72,61 +175,72 @@ func setRule(backendSourcePath, indexPath, ruleCategory, value string, allow boo
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, backendSourcePath)
fmt.Printf("successfully added %s rule for all users on index path '%s' in filesystem path '%s'\n", action, indexPath, fsPath)
} else {
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, backendSourcePath)
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, fsPath)
}
return nil
}
func setUser(username, password string, asAdmin bool) error {
user, err := state.GetUserByUsername(username)
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)
if err != nil {
newUser := users.User{
FrontendUser: users.FrontendUser{
Username: username,
LoginMethod: users.LoginMethodPassword,
Username: username,
LoginMethod: users.LoginMethodPassword,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.BackendScopes = append(newUser.BackendScopes, users.BackendScope{
Path: source.Path,
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
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.Account.Permissions)
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
newUser.Permissions.Admin = asAdmin
err = state.CreateUser(&newUser, password)
err = storage.CreateUser(newUser, newUser.Permissions)
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.TOTPSecret = ""
user.TOTPNonce = ""
user.Password = password
user.TOTPSecret = "" // reset TOTP secret if it exists
user.TOTPNonce = "" // reset TOTP nonce if it exists
user.LoginMethod = users.LoginMethodPassword
if asAdmin {
user.Permissions.Admin = true
}
// Ensure version is set for existing users being updated
if user.Version == 0 {
user.Version = users.CurrentUserMigrationVersion
user.Version = 1
}
err = state.UpdateUser(&user, password, "password", "totpSecret", "totpNonce", "loginMethod", "permissions", "version")
err = store.Users.Save(user, true, false)
if err != nil {
return fmt.Errorf("could not update user: %v", err)
}
@@ -134,27 +248,53 @@ func setUser(username, password string, asAdmin bool) error {
return nil
}
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
// UserDefaults defines default settings for new users.
type UserDefaults struct {
Permissions users.Permissions `yaml:"permissions"`
}
// 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')
@@ -165,6 +305,9 @@ 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)
@@ -179,45 +322,64 @@ func askYesNoQuestion(reader *bufio.Reader, prompt string, defaultValue string)
}
}
func createConfig(configpath string, noInput bool) error {
if noInput {
return fmt.Errorf("'setup' requires interactive input; rerun without --no-input")
// 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
}
reader := bufio.NewReader(os.Stdin)
config := settings.SetDefaults(false)
config.Server.Sources = []*settings.Source{{Path: "./"}}
if configpath == "" {
configpath = "config.yaml"
config := Settings{
Server: Server{
Logging: []LogConfig{
{
Levels: "info|warning|error",
ApiLevels: "info|warning|error",
Output: "stdout",
NoColors: false,
Utc: false,
},
},
Sources: []Source{
{
Path: "",
},
},
},
}
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
break // Valid path found, exit loop
}
}
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.Http.Port = port
break
config.Server.Port = port
break // Port is valid, exit loop
}
fmt.Printf("Error: '%s' is not a valid port. Please enter a number between 1 and 65535.\n", portStr)
}
@@ -238,54 +400,53 @@ func createConfig(configpath string, noInput bool) error {
}
for {
// "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
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
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .db", config.Server.Database)
}
// 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")
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")
// 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")
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 fmt.Errorf("marshaling config: %w", err)
return
}
err = os.WriteFile(configpath, yamlData, 0600)
// Write the YAML data to the file
err = os.WriteFile("config.yaml", yamlData, 0644) // 0644 provides read/write for owner, read for others
if err != nil {
return fmt.Errorf("writing config file: %w", err)
return
}
if _, err := os.Stat(config.Server.DatabaseV2.Path); err == nil {
// cleanup database if it exists
if _, err := os.Stat(config.Server.Database); err == nil {
response := askYesNoQuestion(reader, "Database specified already exists. Move database file to backup to start fresh?", "no")
if !response {
return nil
return
}
backupPath := config.Server.DatabaseV2.Path + ".bak"
err = os.Rename(config.Server.DatabaseV2.Path, backupPath)
// move database file to backup
backupPath := config.Server.Database + ".bak"
err = os.Rename(config.Server.Database, 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() {
@@ -298,6 +459,7 @@ func generateYaml() {
if generateConfig {
os.Exit(0)
}
}
func SplitByMultiple(str string) []string {
-185
View File
@@ -1,185 +0,0 @@
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
@@ -1,83 +0,0 @@
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
@@ -1,227 +0,0 @@
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
@@ -1,483 +0,0 @@
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
@@ -1,165 +0,0 @@
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
@@ -1,74 +0,0 @@
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"))
}
@@ -1,89 +0,0 @@
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
@@ -1,142 +0,0 @@
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
@@ -1,124 +0,0 @@
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/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
+51 -91
View File
@@ -3,7 +3,6 @@ package cmd
import (
"context"
"fmt"
"io/fs"
"os"
"os/signal"
"syscall"
@@ -11,59 +10,52 @@ import (
_ "net/http/pprof"
"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/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/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/swaggo/swag"
)
var runtimeApp *app.App
var store *bolt.BoltStore
func initializeDatabase(configFile string) bool {
func getStore(configFile string) bool {
// Use the config file (global flag)
settings.Initialize(configFile)
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)
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
if err != nil {
logger.Fatalf("could not initialize state: %v", err)
logger.Fatalf("could not load db info: %v", err)
}
runtimeApp, err = app.WireServices(store)
if err != nil {
logger.Fatalf("could not wire services: %v", err)
}
return existingDb
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
`)
}
func StartFilebrowser() {
keepGoing, dbExists := runCLI()
keepGoing := 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 != "" {
@@ -82,17 +74,17 @@ func StartFilebrowser() {
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
// Dev mode enables development features like template hot-reloading
_, 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)
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)
}
err = utils.SetInvalidPasswordHash()
if err != nil {
logger.Fatalf("Failed to set security hash: %v", err)
// 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
if !settings.Env.IsDevMode {
settings.Env.EmbeddedFs = os.IsNotExist(err)
}
sourceList := []string{}
@@ -116,7 +108,7 @@ func StartFilebrowser() {
}
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = settings.Config.Http.BaseURL
swagInfo.BasePath = serverConfig.BaseURL
swag.Register(docs.SwaggerInfo.InstanceName(), swagInfo)
// initialize indexing and schedule indexing ever n minutes (default 5)
if len(settings.Config.Server.SourceMap) == 0 {
@@ -124,29 +116,25 @@ func StartFilebrowser() {
}
// Initialize shared index database before starting HTTP service
isNewDb, err := indexing.InitializeIndexDB()
if err != nil {
if err := indexing.InitializeIndexDB(); err != nil {
logger.Fatalf("Failed to initialize index database: %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)
}
// Set indexing storage for persistence
if store != nil && store.Indexing != nil {
indexing.SetIndexingStorage(store.Indexing)
}
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false, isNewDb)
go indexing.Initialize(source, false)
}
analytics.StartReporter()
validateUserInfo(!dbExists)
validateOfficeIntegration()
validateAccessRules()
validateShareInfo()
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, &serverConfig, runtimeApp, shutdownComplete); err != nil {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
logger.Fatalf("Error starting filebrowser: %v", err)
}
close(done) // Signal that the server has stopped
@@ -176,50 +164,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, serverConfig *settings.Server, a *app.App, shutdownComplete chan struct{}) error {
func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.Server, 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 := web.GetEmbeddedAssets()
subAssets, err := fs.Sub(embeddedAssets, "embed")
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
fileutils.InitAssetFS(subAssets, true)
} else {
fileutils.InitAssetFS(nil, false)
}
// Start preview service
// setup disk cache
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
logger.Fatalf("Error starting preview service: %v", err)
}
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
// Initialize PWA manifest after icons are generated
icons.InitializePWAManifest()
web.StartHttp(ctx, web.Deps{
Store: a.Store,
Files: a.Files,
Auth: a.Auth,
}, shutdownComplete)
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+27 -25
View File
@@ -1,15 +1,18 @@
package cmd
import (
"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/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// validateShareInfo migrates share links to add default sidebar links
func validateShareInfo() {
shares, err := state.GetAllShares()
if store.Share == nil {
return
}
// Get all shares
shares, err := store.Share.All()
if err != nil {
logger.Debugf("No shares found or error getting shares: %v", err)
return
@@ -19,28 +22,27 @@ func validateShareInfo() {
for _, link := range shares {
// Check if this share needs migration (version not set or 0)
if link.Version == 0 {
hash := link.Hash
// 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
// Save the updated share
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 {
if err := store.Share.Save(link); err != nil {
logger.Errorf("Failed to migrate share %s: %v", link.Hash, err)
continue
}
+110 -162
View File
@@ -4,13 +4,10 @@ import (
"fmt"
"os"
"reflect"
"strings"
"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/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -18,135 +15,97 @@ var createBackup = false
func validateUserInfo(newDB bool) {
// update source info for users if names/sources/paths might have changed
usersList, err := state.GetAllUsers()
usersList, err := store.Users.Gets()
if err != nil {
logger.Fatalf("could not load users: %v", err)
}
for i := range usersList {
user := &usersList[i]
changedFields := make([]string, 0, 8)
for _, user := range usersList {
changePass := false
updateUser := false
if updateUserScopes(user) {
changedFields = append(changedFields, "backendScopes")
updateUser = true
}
if updatePermissions(user) {
changedFields = append(changedFields, "permissions", "perm", "version")
}
if updateSourcePermissions(user) {
changedFields = append(changedFields, "backendScopes", "backendSourcePermissions", "version")
updateUser = true
}
if updatePreviewSettings(user) {
changedFields = append(changedFields, "preview")
updateUser = true
}
if updateLoginType(user) {
changedFields = append(changedFields, "loginMethod")
updateUser = true
}
if updateShowFirstLogin(user) {
changedFields = append(changedFields, "showFirstLogin")
updateUser = true
}
if updateSidebarLinks(user) {
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")
updateUser = true
}
adminUser := settings.Config.Auth.AdminUsername
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 {
adminPass := settings.Config.Auth.AdminPassword
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
if user.Username == adminUser && adminPass != "" && passwordEnabled {
logger.Info("Resetting admin user to default username and password.")
user.Permissions = settings.AdminPerms()
user.Permissions.Admin = true
user.Password = settings.Config.Auth.AdminPassword
changedFields = append(changedFields, "permissions", "password")
updateUser = true
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 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)
if err != nil {
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
logger.Errorf("could not update user: %v", err)
}
}
plainPass := ""
if changePass {
plainPass = user.Password
}
if err := state.UpdateUser(user, plainPass, changedFields...); err != nil {
logger.Errorf("could not update user: %v", err)
}
}
}
func dedupeFields(fields []string) []string {
seen := make(map[string]struct{}, len(fields))
out := make([]string, 0, len(fields))
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
key := strings.ToLower(field)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, field)
}
return out
}
func updateUserScopes(user *users.User) bool {
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
user.BackendScopes = newScopes
return changed
}
newScopes := []users.SourceScope{}
seen := make(map[string]struct{})
func updateSourcePermissions(user *users.User) bool {
changed := false
if user.Version < users.SourcePermissionsMigrationVersion {
if users.MigrateToSourcePermissions(user) {
changed = true
// 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 {
continue
}
newScopes = append(newScopes, users.SourceScope{
Name: src.Path,
Scope: existingScope.Scope,
})
seen[src.Path] = struct{}{}
}
// 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)
}
}
if users.EnsureSourcePermissionsForScopes(
user,
settings.DefaultSourceFilePermissions(),
settings.AdminSourceFilePermissions(),
) {
changed = true
}
changed := !reflect.DeepEqual(user.Scopes, newScopes)
user.Scopes = newScopes
return changed
}
@@ -160,7 +119,7 @@ func updateShowFirstLogin(user *users.User) bool {
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
if user.Version >= 1 {
if user.Version == 1 {
return false
}
updateUser := true
@@ -206,7 +165,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = 2
user.Version = 1
if updateUser {
createBackup = true
}
@@ -231,70 +190,59 @@ func updatePreviewSettings(user *users.User) bool {
return false
}
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
// updateSidebarLinks checks if user has stale source links and rebuilds them if needed
func updateSidebarLinks(user *users.User) bool {
updated := false
// Count source links and check if any are still valid
sourceLinksCount := 0
validSourceLinksCount := 0
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
updated = true
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 !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 {
// If user has no source links, don't update anything
if sourceLinksCount == 0 {
return false
}
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)
}
}
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
// 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)
}
}
sanitized := users.SanitizeTokenPermissions(token.Permissions)
if sanitized != token.Permissions {
token.Permissions = sanitized
user.Tokens[name] = token
changed = true
// 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
}
return changed
// User has at least one valid source link, no update needed
return false
}
-124
View File
@@ -1,124 +0,0 @@
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
@@ -1,79 +0,0 @@
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)
}
}
+65 -67
View File
@@ -4,8 +4,8 @@ import (
"reflect"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/stretchr/testify/assert"
)
@@ -25,7 +25,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
"sourceA": &sourceA,
"sourceB": &sourceB,
}
settings.InitializeUserResolvers()
// ---------------------
// Test Cases Definition
@@ -33,110 +32,108 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
testCases := []struct {
name string
user *users.User
expectedPhase1 []users.BackendScope
expectedPhase2 []users.BackendScope
expectedPhase1 []users.SourceScope
expectedPhase2 []users.SourceScope
}{
{
name: "Single valid scope",
user: &users.User{
BackendScopes: []users.BackendScope{
{Scope: "/home", Path: "/pathA"},
Scopes: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
},
},
expectedPhase1: []users.BackendScope{
{Scope: "/home", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
expectedPhase1: []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"},
expectedPhase2: []users.SourceScope{
{Scope: "/home", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
},
},
{
name: "Single empty scope path",
user: &users.User{
BackendScopes: []users.BackendScope{
{Scope: "", Path: "/pathB"},
Scopes: []users.SourceScope{
{Scope: "", Name: "/pathB"},
},
},
expectedPhase1: []users.BackendScope{
{Scope: "/defaultB", Path: "/pathB"},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultB", Name: "/pathB"},
},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
},
},
{
name: "Two scopes, one includes username in path",
user: &users.User{
FrontendUser: users.FrontendUser{Username: "user123"},
BackendScopes: []users.BackendScope{
{Scope: "/home/user123", Path: "/pathA"},
{Scope: "/data", Path: "/pathB"},
Username: "user123",
Scopes: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
},
},
expectedPhase1: []users.BackendScope{
{Scope: "/home/user123", Path: "/pathA"},
{Scope: "/data", Path: "/pathB"},
expectedPhase1: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
},
expectedPhase2: []users.BackendScope{
{Scope: "/home/user123", Path: "/pathA"},
{Scope: "/data", Path: "/pathB"},
expectedPhase2: []users.SourceScope{
{Scope: "/home/user123", Name: "/pathA"},
{Scope: "/data", Name: "/pathB"},
},
},
{
name: "Two scopes, one with empty name",
user: &users.User{
BackendScopes: []users.BackendScope{
{Scope: "/home", Path: "/pathB"},
{Scope: "/data", Path: "/somethingElse"},
Scopes: []users.SourceScope{
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
},
},
expectedPhase1: []users.BackendScope{
{Scope: "/home", Path: "/pathB"},
{Scope: "/data", Path: "/somethingElse"},
expectedPhase1: []users.SourceScope{
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/home", Path: "/pathB"},
{Scope: "/data", Path: "/somethingElse"},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/home", Name: "/pathB"},
{Scope: "/data", Name: "/somethingElse"},
},
},
{
name: "No scopes at all",
user: &users.User{
BackendScopes: []users.BackendScope{},
Scopes: []users.SourceScope{},
},
expectedPhase1: []users.BackendScope{
{Scope: "/defaultB", Path: "/pathB"},
expectedPhase1: []users.SourceScope{
{Scope: "/defaultB", Name: "/pathB"},
},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA", Name: "/pathA"},
{Scope: "/defaultB", Name: "/pathB"},
},
},
{
name: "All user Scope and source change",
user: &users.User{
FrontendUser: users.FrontendUser{Username: "user123"},
BackendScopes: []users.BackendScope{
{Scope: "/defaultC/user123", Path: "/pathC"},
{Scope: "/defaultA/user123", Path: "/pathA"},
{Scope: "/defaultB/user123", Path: "/pathB"},
Username: "user123",
Scopes: []users.SourceScope{
{Scope: "/defaultC/user123", Name: "/pathC"},
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
},
},
// 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"},
expectedPhase1: []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"},
expectedPhase2: []users.SourceScope{
{Scope: "/defaultA/user123", Name: "/pathA"},
{Scope: "/defaultB/user123", Name: "/pathB"},
{Scope: "/defaultC/user123", Name: "/pathC"},
},
},
}
@@ -146,10 +143,10 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
// ---------------------
for _, tc := range testCases {
t.Run(tc.name+"_Phase1", func(t *testing.T) {
originalScopes := tc.user.BackendScopes
originalScopes := tc.user.Scopes
updated := updateUserScopes(tc.user)
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)
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)
})
}
@@ -159,6 +156,7 @@ 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}
@@ -174,10 +172,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.BackendScopes
originalScopes := tc.user.Scopes
updated := updateUserScopes(tc.user)
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)
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)
})
}
@@ -19,19 +19,10 @@ var (
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not configured")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
ErrNotViewable = errors.New("directory or item is not viewable")
ErrPathEscapesScope = errors.New("resolved path escapes scope")
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
ErrUseMediaStream = errors.New("use /media/stream for audio and video")
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
ErrPasskeyMFARequired = errors.New("passkey MFA is required")
ErrPasskeyVerification = errors.New("passkey verification failed")
ErrPasskeyNoCredential = errors.New("no passkey credentials found for user")
ErrPasskeyExists = errors.New("passkey credential already exists")
ErrPasskeyNotEnabled = errors.New("passkey authentication is not enabled")
ErrPasskeyInvalidSession = errors.New("invalid or expired passkey session")
)
+135
View File
@@ -0,0 +1,135 @@
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
}
@@ -2,13 +2,12 @@ package settings
import (
"bytes"
"context"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
@@ -16,11 +15,10 @@ import (
"github.com/go-playground/validator/v10"
"github.com/goccy/go-yaml"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -52,21 +50,23 @@ func Initialize(configFile string) {
}
setupFs()
setupServer()
setupHttp()
setupAuth(false)
setupSources(false)
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
setupUrls()
warnHttpProxyConfig()
setupFrontend(false)
setupMedia(false)
setupMedia()
}
func setupServer() {
if Config.Server.ListenAddress == "" {
Config.Server.ListenAddress = "0.0.0.0"
}
// Check environment variable first (overrides config file)
if os.Getenv("FILEBROWSER_SQL_WAL") == "true" {
Config.Server.IndexSqlConfig.WalMode = true
}
// WalMode is false by default (OFF journaling)
}
func setupEnv() {
@@ -80,42 +80,6 @@ func setupEnv() {
}
}
func setupHttp() {
if err := ValidateHttpConfig(Config.Http); err != nil {
logger.Fatal(err.Error())
}
if Config.Http.ListenAddress == "" {
Config.Http.ListenAddress = "0.0.0.0"
}
}
func warnHttpProxyConfig() {
if Env.IsDevMode || Env.IsPlaywright {
return
}
if Config.Http.BaseURL != "/" && !Config.Http.TrustProxyHeaders {
logger.Warning(`http.baseURL is not "/" but http.trustProxyHeaders is false. Behind a reverse proxy on a subpath, set trustProxyHeaders: true so cookies, client IP, OIDC redirects, and URLs resolve correctly. See https://filebrowserquantum.com/en/docs/configuration/http/#trustproxyheaders`)
}
if !Env.IsDevMode {
if u := Config.Http.ExternalUrl; u != "" && strings.HasPrefix(strings.ToLower(u), "http://") {
logger.Warning("http.externalUrl uses http when the url should be https for a production environment")
}
if u := Config.Integrations.OnlyOffice.Url; u != "" && strings.HasPrefix(strings.ToLower(u), "http://") {
logger.Warning("integrations.office.url uses http when it should be https for a production environment.")
}
}
}
func warnOidcProxyHeaders() {
if !Config.Auth.Methods.OidcAuth.Enabled {
return
}
if Config.Http.TrustProxyHeaders {
return
}
logger.Warning("OIDC is enabled but http.trustProxyHeaders is false. Behind a reverse proxy, FileBrowser builds the OIDC redirect_uri from the incoming request; set trustProxyHeaders: true and configure your proxy to send X-Forwarded-Proto and X-Forwarded-Host. http.externalUrl does not apply to OIDC redirects.")
}
func setupFs() {
// Convert permission values (like 644, 755) to octal interpretation
filePermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateFilePermission, 8, 32)
@@ -128,15 +92,11 @@ func setupFs() {
Config.Server.Filesystem.CreateDirectoryPermission = "755"
dirPermOctal, _ = strconv.ParseUint("755", 8, 32)
}
fileutils.SetFsPermissions(uint32(filePermOctal), uint32(dirPermOctal))
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
// Perform mandatory cache directory speed test
testCacheDirSpeed()
if err := PrepareDownloadSpoolDir(); err != nil {
logger.Fatalf("cacheDir failed to prepare download spool: %v", err)
}
logger.Infof("cache directory setup successfully: %v", Config.Server.CacheDir)
}
@@ -320,80 +280,42 @@ func setupFrontend(generate bool) {
loadCustomFavicon()
}
func setupMedia(generate bool) {
// Save user's explicit config before applying defaults
userImageConfig := make(map[ImagePreviewType]*bool)
for k, v := range Config.Integrations.Media.Convert.ImagePreview {
userImageConfig[k] = v
func setupMedia() {
// If VideoPreview is not initialized, initialize with all types enabled
if Config.Integrations.Media.Convert.VideoPreview == nil {
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
}
// Re-initialize with defaults (gets wiped out during YAML unmarshal)
Config.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
Config.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
Config.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
// Apply user overrides (only for keys explicitly set in YAML)
for k, v := range userImageConfig {
Config.Integrations.Media.Convert.ImagePreview[k] = v
// If VideoPreview map is empty, it means user didn't configure any video preview settings
// In this case, enable all by default
if len(Config.Integrations.Media.Convert.VideoPreview) == 0 {
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
}
// VideoPreview: Merge user config with defaults
// Save user's explicit config from YAML
userVideoConfig := make(map[VideoPreviewType]*bool)
// User has explicitly configured some video preview settings
// Start with all enabled, then apply user overrides
userConfig := make(map[VideoPreviewType]bool)
for k, v := range Config.Integrations.Media.Convert.VideoPreview {
userVideoConfig[k] = v
userConfig[k] = v
}
// Re-initialize with defaults (all enabled)
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
// Reset to defaults (all enabled)
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
// Apply user overrides (only for keys explicitly set in YAML)
for k, v := range userVideoConfig {
// Apply user overrides (only for explicitly set values)
for k, v := range userConfig {
Config.Integrations.Media.Convert.VideoPreview[k] = v
}
if !generate && !isGoTest() {
setupFFmpegIntegration()
}
}
func isGoTest() bool {
return flag.Lookup("test.v") != nil
}
func setupFFmpegIntegration() {
maxConcurrent := Config.Server.NumImageProcessors
if maxConcurrent < 1 {
maxConcurrent = 4
}
ffmpegConcurrency := (maxConcurrent + 1) / 2
err := ffmpeg.Initialize(context.Background(), ffmpeg.InitOptions{
FFmpegPath: Config.Integrations.Media.FfmpegPath,
MaxConcurrent: ffmpegConcurrency,
CacheDir: Config.Server.CacheDir,
SkipHWTests: !Config.Integrations.Media.HardwareAcceleration,
HardwareAcceleration: Config.Integrations.Media.HardwareAcceleration,
})
if err != nil {
logger.Warningf("ffmpeg unavailable: %v", err)
Env.FFmpegAvailable = false
Env.FFmpegPath = ""
Env.FFprobePath = ""
return
}
svc := ffmpeg.Get()
if svc == nil {
Env.FFmpegAvailable = false
return
}
Env.FFmpegAvailable = true
Env.FFmpegPath = svc.FFmpegPath()
Env.FFprobePath = svc.FFprobePath()
}
func setupSources(generate bool) {
@@ -416,7 +338,11 @@ func setupSources(generate bool) {
if name == "\\" {
name = strings.Split(realPath, ":")[0]
}
source.Path = realPath // use absolute path
if generate {
source.Path = generatorPath // use placeholder path
} else {
source.Path = realPath // use absolute path
}
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
if ok {
@@ -425,10 +351,6 @@ func setupSources(generate bool) {
source.Name = name
}
}
if generate {
source.Path = generatorPath // use placeholder path
source.Name = "Source Name"
}
modifyExcludeInclude(source)
setConditionals(source)
if source.Config.DefaultUserScope == "" {
@@ -440,7 +362,7 @@ func setupSources(generate bool) {
}
// clean up the in memory source list to be accurate and unique
sourceList := []*Source{}
defaultScopes := []users.BackendScope{}
defaultScopes := []users.SourceScope{}
allSourceNames := []string{}
if len(Config.Server.Sources) == 1 {
Config.Server.Sources[0].Config.DefaultEnabled = true
@@ -455,8 +377,8 @@ func setupSources(generate bool) {
if ok && !slices.Contains(allSourceNames, source.Name) {
sourceList = append(sourceList, source)
if source.Config.DefaultEnabled {
defaultScopes = append(defaultScopes, users.BackendScope{
Path: source.Path,
defaultScopes = append(defaultScopes, users.SourceScope{
Name: source.Path,
Scope: source.Config.DefaultUserScope,
})
}
@@ -467,40 +389,23 @@ func setupSources(generate bool) {
}
Config.UserDefaults.DefaultScopes = defaultScopes
Config.Server.Sources = sourceList
finalizeConfigSourceDefaultPermissions()
}
func finalizeConfigSourceDefaultPermissions() {
Env.ConfigSourceDefaultPermissions = nil
for _, source := range Config.Server.Sources {
if source == nil || len(source.Config.DefaultPermissionsFromConfig) == 0 {
continue
}
Env.ConfigSourceDefaultPermissions = source.Config.DefaultPermissionsFromConfig
perms := NormalizeSourceFilePermissions(source.Config.DefaultPermissions)
for flag, val := range source.Config.DefaultPermissionsFromConfig {
setSourcePermissionFlag(&perms, flag, val)
}
source.Config.DefaultPermissions = users.MarkSourceFilePermissionsConfigured(perms)
return
}
}
func setupUrls() {
baseurl := strings.Trim(Config.Http.BaseURL, "/")
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Http.BaseURL = "/"
Config.Server.BaseURL = "/"
} else {
Config.Http.BaseURL = "/" + baseurl + "/"
Config.Server.BaseURL = "/" + baseurl + "/"
}
if Config.Http.BaseURL != "/" {
if Config.Http.ExternalUrl != "" {
Config.Http.ExternalUrl = strings.TrimSuffix(Config.Http.ExternalUrl, "/") + "/"
Config.Http.ExternalUrl = strings.TrimSuffix(Config.Http.ExternalUrl, Config.Http.BaseURL)
if Config.Server.BaseURL != "/" {
if Config.Server.ExternalUrl != "" {
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, "/") + "/"
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, Config.Server.BaseURL)
}
if Config.Http.InternalUrl != "" {
Config.Http.InternalUrl = strings.TrimSuffix(Config.Http.InternalUrl, "/") + "/"
Config.Http.InternalUrl = strings.TrimSuffix(Config.Http.InternalUrl, Config.Http.BaseURL)
if Config.Server.InternalUrl != "" {
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, "/") + "/"
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, Config.Server.BaseURL)
}
}
Config.Integrations.OnlyOffice.Url = strings.Trim(Config.Integrations.OnlyOffice.Url, "/")
@@ -515,43 +420,21 @@ func setupAuth(generate bool) {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
if Config.Auth.Methods.ProxyAuth.Enabled {
applyAuthCommonDefaults(&Config.Auth.Methods.ProxyAuth.AuthCommon)
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
}
if Config.Auth.Methods.OidcAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
}
if Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
Config.Auth.AuthMethods = []string{"disabled"}
}
if Config.Auth.Methods.OidcAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
err := validateOidcAuth()
if err != nil && !generate {
logger.Fatalf("Error validating OIDC auth: %v", err)
}
logger.Info("OIDC Auth configured successfully")
if !generate {
warnOidcProxyHeaders()
}
}
if Config.Auth.Methods.LdapAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "ldap")
err := ValidateLdapAuth()
if err != nil && !generate {
logger.Fatalf("Error validating LDAP auth: %v", err)
}
logger.Info("LDAP Auth configured successfully")
}
if Config.Auth.Methods.JwtAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "jwt")
err := ValidateJwtAuth()
if err != nil && !generate {
logger.Fatalf("Error validating JWT auth: %v", err)
}
logger.Info("JWT Auth configured successfully")
}
if Config.Auth.Methods.PasskeyAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "passkey")
logger.Info("Passkey Auth configured successfully")
}
// use password auth as default if no auth methods are set
@@ -559,7 +442,7 @@ func setupAuth(generate bool) {
Config.Auth.Methods.PasswordAuth.Enabled = true
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
Config.UserDefaults.Account.LoginMethod = Config.Auth.AuthMethods[0]
Config.UserDefaults.LoginMethod = Config.Auth.AuthMethods[0]
}
@@ -571,28 +454,23 @@ func setupLogging() {
},
}
}
for i := range Config.Server.Logging {
cfg := &Config.Server.Logging[i]
for _, logConfig := range Config.Server.Logging {
// Enable debug logging automatically in dev mode
levels := cfg.Levels
levels := logConfig.Levels
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
levels = "info|warning|error|debug"
}
pattern := cfg.ApiFilter
if pattern == "" {
pattern = "^/health|^/favicon.ico|^/static|^/public/static"
logConfig := logger.JsonConfig{
Levels: levels,
ApiLevels: logConfig.ApiLevels,
Output: logConfig.Output,
Utc: logConfig.Utc,
NoColors: logConfig.NoColors,
Json: logConfig.Json,
Structured: false,
}
jsonCfg := logger.JsonConfig{
Levels: levels,
ApiLevels: cfg.ApiLevels,
Output: cfg.Output,
Utc: cfg.Utc,
NoColors: cfg.NoColors,
Json: cfg.Json,
Structured: false,
ApiPathExclude: pattern,
}
err := logger.EnableCompatibilityMode(jsonCfg)
err := logger.EnableCompatibilityMode(logConfig)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
@@ -600,7 +478,7 @@ func setupLogging() {
}
func loadConfigWithDefaults(configFile string, generate bool) error {
Config = SetDefaults(generate)
Config = setDefaults(generate)
// Check if config file exists
if _, err := os.Stat(configFile); err != nil {
@@ -613,7 +491,7 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
},
}
loadEnvConfig()
return ResolveDatabasePaths()
return nil
}
// Try multi-file config first (combine all YAML files in the directory)
@@ -638,7 +516,6 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
"integrations": true,
"frontend": true,
"userDefaults": true,
"http": true,
}
filteredConfig := make(map[string]interface{})
@@ -648,19 +525,6 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
}
}
if udRaw, ok := filteredConfig["userDefaults"]; ok {
Env.ConfigUserDefaultsSpecified = true
if udMap, ok := udRaw.(map[string]interface{}); ok {
Env.ConfigUserDefaultsSpecifiedPaths = CollectMapLeafPaths(udMap, "")
}
}
// check if database is set in the config
val, ok := filteredConfig["server"].(map[string]interface{})["database"].(string)
if ok && val != "" {
return fmt.Errorf("old database path is set in the config, please migrate your database to SQLite")
}
// Marshal the filtered config back to YAML
filteredYAML, err := yaml.Marshal(filteredConfig)
if err != nil {
@@ -674,67 +538,10 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
return fmt.Errorf("error unmarshaling YAML data: %v", err)
}
if err := applyLoadedUserDefaultsFromConfig(generate); err != nil {
return err
}
attachSourceDefaultPermissionsFromConfig(filteredConfig)
loadEnvConfig()
return ResolveDatabasePaths()
}
// applyLoadedUserDefaultsFromConfig merges config-specified userDefaults paths onto
// SetDefaults so partial YAML (e.g. only account.permissions.share) does not zero
// unspecified sections such as preview thumbnails or fileViewer.autoplayMedia.
func applyLoadedUserDefaultsFromConfig(generate bool) error {
if !Env.ConfigUserDefaultsSpecified {
return nil
}
base := SetDefaults(generate).UserDefaults
merged, err := ApplyConfigSpecifiedPathsToUserDefaults(base, Config.UserDefaults)
if err != nil {
return fmt.Errorf("merge user defaults with config: %w", err)
}
Config.UserDefaults = merged
return nil
}
func attachSourceDefaultPermissionsFromConfig(raw map[string]interface{}) {
server, ok := raw["server"].(map[string]interface{})
if !ok {
return
}
sources, ok := server["sources"].([]interface{})
if !ok {
return
}
for i, srcRaw := range sources {
if i >= len(Config.Server.Sources) || Config.Server.Sources[i] == nil {
continue
}
src, ok := srcRaw.(map[string]interface{})
if !ok {
continue
}
config, ok := src["config"].(map[string]interface{})
if !ok {
continue
}
dp, ok := config["defaultPermissions"].(map[string]interface{})
if !ok || len(dp) == 0 {
continue
}
fromConfig := make(map[string]bool, len(dp))
for key, val := range dp {
if b, ok := val.(bool); ok {
fromConfig[key] = b
}
}
Config.Server.Sources[i].Config.DefaultPermissionsFromConfig = fromConfig
}
}
func ValidateConfig(config Settings) error {
validate := validator.New()
@@ -817,45 +624,42 @@ func loadEnvConfig() {
logger.Info("Using ReCaptcha Secret from FILEBROWSER_RECAPTCHA_SECRET environment variable")
}
ldapUserPassword := os.Getenv("FILEBROWSER_LDAP_USER_PASSWORD")
if ldapUserPassword != "" {
Config.Auth.Methods.LdapAuth.UserPassword = ldapUserPassword
logger.Info("Using LDAP bind password from FILEBROWSER_LDAP_USER_PASSWORD environment variable")
}
}
func SetDefaults(generate bool) Settings {
// New SQLite database path
databaseV2 := os.Getenv("FILEBROWSER_DATABASE_PATH")
if databaseV2 == "" {
databaseV2 = "filebrowser.sqlite"
func setDefaults(generate bool) Settings {
// get number of CPUs available
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
cpus := runtime.NumCPU()
if cpus > 0 && !generate {
numCpus = cpus
}
database := os.Getenv("FILEBROWSER_DATABASE")
if database == "" {
database = "database.db"
} else {
// check if database file exists
if _, err := os.Stat(database); os.IsNotExist(err) {
database = "database.db"
}
}
if _, err := os.Stat(database); os.IsNotExist(err) {
logger.Warning("database file could not be found. If this is unexpected, the default path is `./database.db`, but it can be configured in the config file under `server.database`.")
}
s := Settings{
Http: Http{
Port: 80,
BaseURL: "",
},
Server: Server{
NumImageProcessors: 4,
DatabaseV2: Database{
Path: databaseV2,
Activity: ActivityConfig{
RetentionDays: 30,
FlushIntervalSeconds: 10,
MaxBufferSize: 10000,
},
},
SourceMap: map[string]*Source{},
NameToSource: map[string]*Source{},
CacheDir: "tmp",
MaxArchiveSizeGB: 20,
Port: 80,
NumImageProcessors: numCpus,
BaseURL: "",
Database: database,
SourceMap: map[string]*Source{},
NameToSource: map[string]*Source{},
MaxArchiveSizeGB: 50,
CacheDir: "tmp",
IndexSqlConfig: IndexSqlConfig{
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
StartupIntegrityCheck: IndexStartupIntegrityQuickCheck,
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
},
Filesystem: Filesystem{
CreateFilePermission: "644",
@@ -876,91 +680,57 @@ func SetDefaults(generate bool) Settings {
Frontend: Frontend{
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
// New organized structure
Sidebar: UserDefaultsSidebar{
DisableQuickToggles: false,
HideFileActions: false,
DisableHideOnPreview: false,
Sticky: true,
HideFiles: false,
ShowTools: boolPtr(true),
},
Listing: UserDefaultsListing{
DeleteWithoutConfirming: false,
DateFormat: false,
ShowHidden: false,
QuickDownload: false,
ShowSelectMultiple: false,
SingleClick: false,
HideFileExt: "",
ShowCopyPath: false,
DeleteAfterArchive: true,
ViewMode: "normal",
GallerySize: 3,
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: UserDefaultsPermissions{
Modify: false,
Share: false,
Admin: false,
Api: false,
Download: boolPtr(true), // defaults to true
},
Preview: UserDefaultsPreview{
HighQuality: boolPtr(true),
Image: boolPtr(true),
Video: boolPtr(true),
Audio: boolPtr(true),
MotionVideoPreview: boolPtr(true),
Office: boolPtr(true),
PopUp: boolPtr(true),
DisablePreviewExt: "",
HighQuality: boolPtr(true),
AutoplayMedia: boolPtr(true),
Folder: boolPtr(true),
Models: boolPtr(true),
},
FileViewer: UserDefaultsFileViewer{
EditorQuickSave: false,
AutoplayMedia: boolPtr(true),
DisableViewingExt: "",
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
PreferEditorForMarkdown: false,
DebugOffice: false,
},
Search: UserDefaultsSearch{
DisableOptions: false,
},
UI: UserDefaultsUI{
DarkMode: boolPtr(true),
ThemeColor: "var(--blue)",
CustomTheme: "",
Locale: "en",
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
UploadChunkSize: 10, // 10MB
DownloadChunkSize: 0, // 0MB, default to no chunking
},
Account: UserDefaultsAccount{
Permissions: UserDefaultsAccountPermissions{
Api: false,
Admin: false,
Share: false,
Realtime: false,
},
LockPassword: false,
DisableSettings: false,
LoginMethod: "",
DisableUpdateNotifications: false,
MaxConcurrent: 10,
UploadChunkSize: 10, // 10MB
},
},
}
// Initialize ImagePreview map with defaults
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
s.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
s.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
// Initialize VideoPreview map with defaults (all enabled)
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
// Initialize ImagePreview map with all supported types set to false by default
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]bool)
for _, t := range AllImagePreviewTypes {
s.Integrations.Media.Convert.ImagePreview[t] = false
}
// Initialize VideoPreview map with all supported types set to true by default
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
s.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
s.Integrations.Media.Convert.VideoPreview[t] = true
}
return s
}
// validateCustomImage validates a custom image file path and returns the absolute path or error
func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) {
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
// Get absolute path
absolutePath, err = filepath.Abs(configPath)
if err != nil {
@@ -968,11 +738,16 @@ func validateCustomImage(configPath, imageName string, allowedFormats []string)
}
// Check if file exists
_, err = os.Stat(absolutePath)
stat, err := os.Stat(absolutePath)
if err != nil {
return "", fmt.Errorf("could not access file: %w", err)
}
// Check file size
if stat.Size() > maxSize {
return "", fmt.Errorf("file too large (%d bytes), maximum allowed is %d bytes", stat.Size(), maxSize)
}
// Validate file format
ext := strings.ToLower(filepath.Ext(absolutePath))
validFormat := false
@@ -991,7 +766,8 @@ func validateCustomImage(configPath, imageName string, allowedFormats []string)
func loadCustomFavicon() {
const imageName = "favicon"
allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"}
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".ico", ".png", ".svg"}
// Set default embedded favicon path
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
@@ -1004,7 +780,7 @@ func loadCustomFavicon() {
}
// Validate custom favicon
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
if err != nil {
logger.Warningf("Custom favicon validation failed: %v, using default", err)
Config.Frontend.Favicon = ""
@@ -1018,34 +794,20 @@ func loadCustomFavicon() {
Env.FaviconPath = validatedPath
Env.FaviconIsCustom = true
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
// PWA and platform PNGs are generated at server startup from this path (SVG uses a raster
// sidecar next to the .svg when present; see icons.GeneratePWAIcons).
}
// PWAIconsCacheDir is where startup icon generation writes PNGs (under the server cache dir).
func PWAIconsCacheDir() string {
return filepath.Join(Config.Server.CacheDir, "icons")
}
// Where transient archives for multi-request (Range) downloads are stored to support chunked downloads.
func DownloadCacheDir() string {
return filepath.Join(Config.Server.CacheDir, "downloads")
}
// PrepareDownloadSpoolDir creates the download spool directory and removes any leftover dl-archive-*
func PrepareDownloadSpoolDir() error {
if err := os.MkdirAll(DownloadCacheDir(), fileutils.PermDir); err != nil {
return err
}
return fileutils.ClearDirectoryContents(DownloadCacheDir())
}
func loadLoginIcon() {
const imageName = "login icon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
// Set default embedded icon path - just use favicon.svg (light/dark handled by CSS)
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg"
// Set default embedded icon path based on dark mode preference
isDarkMode := Config.UserDefaults.DarkMode != nil && *Config.UserDefaults.DarkMode
if isDarkMode {
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg" // Dark mode: dark background
} else {
Env.LoginIconEmbeddedPath = "img/icons/favicon-light.svg" // Light mode: light background
}
// Check if a custom login icon path is configured
if Config.Frontend.LoginIcon == "" {
@@ -1055,7 +817,7 @@ func loadLoginIcon() {
}
// Validate custom login icon
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
if err != nil {
logger.Warningf("Custom login icon validation failed: %v, using default", err)
Env.LoginIconPath = Env.LoginIconEmbeddedPath
@@ -1069,9 +831,11 @@ func loadLoginIcon() {
logger.Infof("Using custom login icon: %s", Env.LoginIconPath)
}
// setConditionals builds optimized map structures from source rules for O(1) lookups.
// setConditionalsMap builds optimized map structures from conditional rules for O(1) lookups
func setConditionals(config *Source) {
rules := config.Config.Rules
// Merge rules from both old format (Conditionals.ItemRules) and new format (Rules)
rules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...)
// Initialize the maps structure (only exact match maps for Names)
resolved := ResolvedRulesConfig{
@@ -1089,7 +853,26 @@ func setConditionals(config *Source) {
IgnoreAllZeroSizeFolders: false,
IgnoreAllSymlinks: false,
IndexingDisabled: false,
NoRules: len(rules) == 0,
}
// backwards compatibility
if config.Config.Conditionals.Hidden {
logger.Warning("source.conditionals.hidden is deprecated, use source.rules instead")
resolved.IgnoreAllHidden = true
}
// Backwards compatibility: if old format fields are set, treat as global rules
if config.Config.Conditionals.IgnoreHidden {
logger.Warning("source.conditionals.ignoreHidden is deprecated, use source.rules instead")
resolved.IgnoreAllHidden = true
}
if config.Config.DisableIndexing {
logger.Warning("source.disableIndexing is deprecated, use source.rules instead")
resolved.IndexingDisabled = true
}
if config.Config.Conditionals.ZeroSizeFolders {
logger.Warning("source.conditionals.zeroSizeFolders is deprecated, use source.rules instead")
resolved.IgnoreAllZeroSizeFolders = true
}
// Process all rules and infer global flags from root-level rules
@@ -1139,6 +922,12 @@ func setConditionals(config *Source) {
if rule.IncludeRootItem != "" {
resolved.IncludeRootItems[rule.IncludeRootItem] = struct{}{}
}
if rule.FileNames != "" {
resolved.FileNames[rule.FileNames] = rule
}
if rule.FolderNames != "" {
resolved.FolderNames[rule.FolderNames] = rule
}
if rule.FileName != "" {
resolved.FileNames[rule.FileName] = rule
}
@@ -1171,12 +960,16 @@ func modifyExcludeInclude(config *Source) {
return normalized
}
// Helper to normalize a name-based value (FileNames, FolderNames, StartsWith, EndsWith)
// These match against baseName, so no leading slash
normalizeName := func(value string) string {
// Just trim slashes - names shouldn't have slashes
return strings.Trim(value, "/")
}
// Normalize []ConditionalRule slices for full paths
allRules := config.Config.Rules
// Handle both old format (Conditionals.ItemRules) and new format (Rules)
allRules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...)
for i, rule := range allRules {
// normalize full paths
@@ -1199,10 +992,15 @@ func modifyExcludeInclude(config *Source) {
}
// normalize names
allRules[i].FileNames = normalizeName(rule.FileNames)
allRules[i].FolderNames = normalizeName(rule.FolderNames)
allRules[i].FileName = normalizeName(rule.FileName)
allRules[i].FolderName = normalizeName(rule.FolderName)
}
config.Config.Rules = allRules
// Update the original slices with normalized values
itemRulesLen := len(config.Config.Conditionals.ItemRules)
config.Config.Conditionals.ItemRules = allRules[:itemRulesLen]
config.Config.Rules = allRules[itemRulesLen:]
}
@@ -0,0 +1,13 @@
package settings
func CanConvertImage(ext string) bool {
return MediaEnabled() && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
}
func CanConvertVideo(ext string) bool {
return MediaEnabled() && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
}
func MediaEnabled() bool {
return Env.FFmpegPath != "" && Env.FFprobePath != ""
}
@@ -3,11 +3,45 @@ package settings
import (
"os"
"path/filepath"
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestInitialize(t *testing.T) {
type args struct {
configFile string
}
tests := []struct {
name string
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Initialize(tt.args.configFile)
})
}
}
func Test_setDefaults(t *testing.T) {
tests := []struct {
name string
want Settings
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setDefaults(true); !reflect.DeepEqual(got, tt.want) {
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
}
})
}
}
func TestConfigLoadChanged(t *testing.T) {
// Create isolated test directory
testDir := t.TempDir()
@@ -20,7 +54,7 @@ func TestConfigLoadChanged(t *testing.T) {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := SetDefaults(true)
defaultConfig := setDefaults(true)
err = loadConfigWithDefaults(configFile, true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
@@ -43,9 +77,10 @@ func TestConfigLoadEnvVars(t *testing.T) {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := SetDefaults(true)
defaultConfig := setDefaults(true)
expectedKey := "MYKEY"
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
// mock environment variables
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
err = loadConfigWithDefaults(configFile, true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
@@ -59,33 +94,6 @@ func TestConfigLoadEnvVars(t *testing.T) {
}
}
func TestConfigLoadHttpSection(t *testing.T) {
testDir := t.TempDir()
configContent := []byte(`
server:
sources:
- path: "."
http:
trustProxyHeaders: true
disableRateLimit: true
`)
configFile := filepath.Join(testDir, "config.yaml")
if err := os.WriteFile(configFile, configContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
if err := loadConfigWithDefaults(configFile, true); err != nil {
t.Fatalf("error loading config file: %v", err)
}
if !Config.Http.TrustProxyHeaders {
t.Fatal("expected http.trustProxyHeaders to be true")
}
if !Config.Http.DisableRateLimit {
t.Fatal("expected http.disableRateLimit to be true")
}
}
func TestConfigLoadSpecificValues(t *testing.T) {
// Create isolated test directory
testDir := t.TempDir()
@@ -98,7 +106,7 @@ func TestConfigLoadSpecificValues(t *testing.T) {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := SetDefaults(true)
defaultConfig := setDefaults(true)
err = loadConfigWithDefaults(configFile, true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
@@ -108,7 +116,7 @@ func TestConfigLoadSpecificValues(t *testing.T) {
globalVal interface{}
newVal interface{}
}{
{"Server.DatabaseV2", Config.Server.DatabaseV2, defaultConfig.Server.DatabaseV2},
{"Server.Database", Config.Server.Database, defaultConfig.Server.Database},
}
for _, tc := range testCases {
@@ -1,9 +1,8 @@
package settings
import (
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// InitializeUserResolvers sets up the global resolvers and config providers in the users package
@@ -13,7 +12,8 @@ func InitializeUserResolvers() {
users.SetSourceNameResolver(func(sourceName string) (string, error) {
source, ok := Config.Server.NameToSource[sourceName]
if !ok {
return "", fmt.Errorf("source name not found: %s", sourceName)
logger.Debug("Could not get scope from source name: ", sourceName)
return "", nil
}
return source.Path, nil
})
@@ -53,7 +53,7 @@ func InitializeUserResolvers() {
}
return sources
},
GetDefaultScopes: func() []users.BackendScope {
GetDefaultScopes: func() []users.SourceScope {
return Config.UserDefaults.DefaultScopes
},
})
@@ -13,9 +13,7 @@ import (
"reflect"
"sort"
"strings"
"sync"
"golang.org/x/tools/go/packages"
"gopkg.in/yaml.v3"
)
@@ -28,45 +26,6 @@ type SecretFieldsMap map[string]map[string]bool
// DeprecatedFieldsMap[typeName][fieldName] = true if field is deprecated
type DeprecatedFieldsMap map[string]map[string]bool
var (
collectCommentsCacheMu sync.RWMutex
collectCommentsCache = map[string]collectCommentsCacheEntry{}
)
type collectCommentsCacheEntry struct {
comments CommentsMap
secrets SecretFieldsMap
deprecated DeprecatedFieldsMap
}
func cloneCommentsMap(src CommentsMap) CommentsMap {
if src == nil {
return nil
}
out := make(CommentsMap, len(src))
for typeName, fieldMap := range src {
out[typeName] = make(map[string]string, len(fieldMap))
for fieldName, comment := range fieldMap {
out[typeName][fieldName] = comment
}
}
return out
}
func cloneBoolFieldMap(src map[string]map[string]bool) map[string]map[string]bool {
if src == nil {
return nil
}
out := make(map[string]map[string]bool, len(src))
for typeName, fieldMap := range src {
out[typeName] = make(map[string]bool, len(fieldMap))
for fieldName, v := range fieldMap {
out[typeName][fieldName] = v
}
}
return out
}
// getStringStyle determines whether a string should be quoted in YAML
func getStringStyle(value string) yaml.Style {
// Always quote all strings for consistency
@@ -81,38 +40,42 @@ func CollectComments(srcPath string) (CommentsMap, error) {
// CollectCommentsAndSecrets parses all Go source and returns comments, secrets, and deprecated field mappings
func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
// Determine directories to parse - include settings and users packages
dir := srcPath
if fi, err := os.Stat(srcPath); err == nil && !fi.IsDir() {
if filepath.IsAbs(srcPath) {
// If it's an absolute path to a file, get the directory
dir = filepath.Dir(srcPath)
}
settingsDir, err := filepath.Abs(dir)
if err != nil {
return nil, nil, nil, err
}
collectCommentsCacheMu.RLock()
if cached, ok := collectCommentsCache[settingsDir]; ok && shouldCacheCollectComments(settingsDir) {
collectCommentsCacheMu.RUnlock()
return cloneCommentsMap(cached.comments), cloneBoolFieldMap(cached.secrets), cloneBoolFieldMap(cached.deprecated), nil
}
collectCommentsCacheMu.RUnlock()
// settings lives at backend/pkg/settings; users at backend/internal/database/users
// List of directories to parse for comments
dirsToparse := []string{
settingsDir,
filepath.Join(settingsDir, "..", "..", "internal", "database", "users"),
dir, // settings package
}
// Add users package directory
// From common/settings, go up to backend root, then into database/users
var usersDir string
if filepath.IsAbs(dir) {
// If absolute path, calculate relative to it
usersDir = filepath.Join(filepath.Dir(filepath.Dir(dir)), "database/users")
} else {
// If relative path like "common/settings" or ".", calculate from current working directory
usersDir = "database/users"
}
if absUsersDir, err := filepath.Abs(usersDir); err == nil {
dirsToparse = append(dirsToparse, absUsersDir)
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
// Parse each directory
for _, parseDir := range dirsToparse {
if _, err := os.Stat(parseDir); err != nil {
continue
}
dirComments, dirSecrets, dirDeprecated, err := parseDirectoryComments(parseDir)
if err != nil {
// Log error but continue with other directories
fmt.Printf("Warning: failed to parse directory %s: %v\n", parseDir, err)
continue
}
@@ -145,118 +108,77 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
}
}
entry := collectCommentsCacheEntry{
comments: cloneCommentsMap(comments),
secrets: cloneBoolFieldMap(secrets),
deprecated: cloneBoolFieldMap(deprecated),
}
collectCommentsCacheMu.Lock()
if shouldCacheCollectComments(settingsDir) {
collectCommentsCache[settingsDir] = entry
}
collectCommentsCacheMu.Unlock()
return cloneCommentsMap(comments), cloneBoolFieldMap(secrets), cloneBoolFieldMap(deprecated), nil
return comments, secrets, deprecated, nil
}
// shouldCacheCollectComments avoids caching parses of ephemeral test fixture dirs under os.TempDir().
func shouldCacheCollectComments(settingsDir string) bool {
if _, err := os.Stat(filepath.Join(settingsDir, "structs.go")); err == nil {
return true
}
return !strings.HasPrefix(settingsDir, os.TempDir())
}
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
absDir, err := filepath.Abs(dir)
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
if err != nil {
return nil, nil, nil, err
}
if _, err = os.Stat(absDir); err != nil {
return nil, nil, nil, err
}
var files []*ast.File
cfg := &packages.Config{
Mode: packages.NeedSyntax | packages.NeedFiles,
Dir: absDir,
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
return parser.ParseFile(fset, filename, src, parser.ParseComments)
},
}
pkgs, err := packages.Load(cfg, ".")
if err == nil && len(pkgs) > 0 {
for _, pkg := range pkgs {
files = append(files, pkg.Syntax...)
}
}
if len(files) == 0 {
// Fallback for directories outside a module (e.g. test temp dirs): parse .go files directly
files, err = parseDirGoFiles(absDir)
if err != nil || len(files) == 0 {
return nil, nil, nil, err
}
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
for _, file := range files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
}
}
}
}
@@ -265,29 +187,6 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
return comments, secrets, deprecated, nil
}
// parseDirGoFiles parses all .go files in dir and returns their ASTs. Used when the directory
// is not inside a Go module (e.g. test temp dirs) so packages.Load would fail.
func parseDirGoFiles(dir string) ([]*ast.File, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
var files []*ast.File
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
continue
}
path := filepath.Join(dir, e.Name())
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
files = append(files, f)
}
return files, nil
}
// PathToTypeField represents a mapping from YAML path to Go type and field info
type PathToTypeField struct {
TypeName string
@@ -344,7 +243,7 @@ func buildPathMapRecursive(structType reflect.Type, structValue reflect.Value, c
fieldType := field.Type
// Handle pointers
if fieldType.Kind() == reflect.Pointer {
if fieldType.Kind() == reflect.Ptr {
if fieldValue.IsNil() {
// Skip nil pointers
continue
@@ -537,7 +436,7 @@ func CollectCommentsFromEmbeddedYaml(yamlContent string) (CommentsMap, SecretFie
// parseDefaultsFromEmbeddedYaml creates a default config by parsing the embedded YAML template
func parseDefaultsFromEmbeddedYaml(embeddedYaml string) (*Settings, error) {
// Start with baseline defaults from setDefaults to ensure all fields are initialized
defaultConfig := SetDefaults(true)
defaultConfig := setDefaults(true)
defaultConfig.Server.Sources = []*Source{{Path: "."}}
// Parse the embedded YAML to overlay the documented defaults
@@ -551,10 +450,10 @@ func parseDefaultsFromEmbeddedYaml(embeddedYaml string) (*Settings, error) {
// readEmbeddedYaml attempts to read the embedded config.generated.yaml file
func readEmbeddedYaml() (string, error) {
// Try to read from the relative path that should work when the binary includes embedded files
embeddedContent, err := os.ReadFile("internal/web/dist/config.generated.yaml")
embeddedContent, err := os.ReadFile("http/dist/config.generated.yaml")
if err != nil {
// Try alternative path
embeddedContent, err = os.ReadFile("backend/internal/web/dist/config.generated.yaml")
embeddedContent, err = os.ReadFile("backend/http/dist/config.generated.yaml")
if err != nil {
// Try current directory path
embeddedContent, err = os.ReadFile("dist/config.generated.yaml")
@@ -574,7 +473,7 @@ func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
// buildNodeWithDefaults constructs a yaml.Node for any Go value, skipping fields that match defaults, redacting secrets, and filtering deprecated fields
func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.Value, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
// Dereference pointers
if v.Kind() == reflect.Pointer {
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
}
@@ -619,130 +518,6 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
currentField := v.Field(i)
// Check if this is an inline/embedded field
yamlTag := sf.Tag.Get("yaml")
jsonTag := sf.Tag.Get("json")
isInline := strings.Contains(yamlTag, ",inline") || strings.Contains(jsonTag, ",inline") || sf.Anonymous
// If field is inline, merge its content directly into parent
if isInline && sf.Type.Kind() == reflect.Struct {
// Get the embedded struct's fields and add them directly to this level
embeddedType := sf.Type
embeddedValue := currentField
embeddedTypeName := embeddedType.Name()
for j := 0; j < embeddedType.NumField(); j++ {
embeddedField := embeddedType.Field(j)
// Skip unexported fields
if embeddedField.PkgPath != "" {
continue
}
if embeddedYamlTag := embeddedField.Tag.Get("yaml"); embeddedYamlTag == "-" {
continue
}
if embeddedJsonTag := embeddedField.Tag.Get("json"); embeddedJsonTag == "-" {
continue
}
// Skip deprecated fields
if len(deprecated) > 0 && deprecated[embeddedTypeName][embeddedField.Name] {
continue
}
embeddedFieldValue := embeddedValue.Field(j)
// If we have defaults, compare and skip if values match
if defaults.IsValid() && i < defaults.NumField() {
defaultInlineField := defaults.Field(i)
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
defaultEmbeddedField := defaultInlineField.Field(j)
embeddedCurrentValue := embeddedFieldValue.Interface()
embeddedDefaultValue := defaultEmbeddedField.Interface()
if reflect.DeepEqual(embeddedCurrentValue, embeddedDefaultValue) {
continue
}
}
}
// Determine key name for embedded field
embeddedYamlTag := embeddedField.Tag.Get("yaml")
embeddedJsonTag := embeddedField.Tag.Get("json")
var embeddedKeyName string
if embeddedYamlTag != "" && embeddedYamlTag != "-" {
embeddedKeyName = strings.Split(embeddedYamlTag, ",")[0]
} else if embeddedJsonTag != "" && embeddedJsonTag != "-" {
embeddedKeyName = strings.Split(embeddedJsonTag, ",")[0]
} else {
embeddedKeyName = embeddedField.Name
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: embeddedKeyName}
// Attach comments for embedded field
var parts []string
var comment string
if cm := comm[embeddedTypeName][embeddedField.Name]; cm != "" {
comment = cm
}
if comment != "" {
parts = append(parts, comment)
}
if len(comm) > 0 {
if vt := embeddedField.Tag.Get("validate"); vt != "" {
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
}
}
if len(parts) > 0 {
keyNode.LineComment = strings.Join(parts, " ")
}
// Build value node for embedded field
var valNode *yaml.Node
var err error
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if secrets[embeddedTypeName][embeddedField.Name] && !generateConfig {
fieldValue := embeddedFieldValue.Interface()
if str, ok := fieldValue.(string); ok && str == "" {
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "",
Style: yaml.DoubleQuotedStyle,
}
} else {
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "**hidden**",
Style: yaml.DoubleQuotedStyle,
}
}
} else {
var defaultEmbeddedField reflect.Value
if defaults.IsValid() && i < defaults.NumField() {
defaultInlineField := defaults.Field(i)
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
defaultEmbeddedField = defaultInlineField.Field(j)
}
}
valNode, err = buildNodeWithDefaults(embeddedFieldValue, comm, defaultEmbeddedField, secrets, deprecated)
if err != nil {
return nil, err
}
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
// Skip adding the embedded struct itself as a field
continue
}
// If we have defaults, compare and skip if values match
if defaults.IsValid() && i < defaults.NumField() {
defaultField := defaults.Field(i)
@@ -757,6 +532,8 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
}
// determine key: yaml tag > json tag > field name
yamlTag := sf.Tag.Get("yaml")
jsonTag := sf.Tag.Get("json")
var keyName string
if yamlTag != "" && yamlTag != "-" {
keyName = strings.Split(yamlTag, ",")[0]
@@ -848,7 +625,7 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
elemType := v.Type().Elem()
// Check if element is a struct or pointer to struct
isStructSlice := elemType.Kind() == reflect.Struct ||
(elemType.Kind() == reflect.Pointer && elemType.Elem().Kind() == reflect.Struct)
(elemType.Kind() == reflect.Ptr && elemType.Elem().Kind() == reflect.Struct)
if !isStructSlice {
seq.Style = yaml.FlowStyle
}
@@ -872,6 +649,11 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
}
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Special handling for ImagePreview maps - show all supported types
if isImagePreviewMap(v) {
return buildImagePreviewMap(v, comm, secrets, deprecated)
}
// Get and sort map keys alphabetically for consistent output
mapKeys := v.MapKeys()
keyStrings := make([]string, len(mapKeys))
@@ -925,26 +707,41 @@ func GenerateYaml() {
{
Path: ".",
Config: SourceConfig{
Rules: []ConditionalRule{
{}, // Add empty sample rule to show all fields in generated YAML
Conditionals: ConditionalFilter{
ItemRules: []ConditionalRule{
{}, // Add empty sample rule to show all fields in generated YAML
},
},
},
},
}
setupLogging()
setupFs()
setupAuth(true)
// Save original paths before setupSources modifies them (for YAML generation)
originalPaths := make(map[*Source]string)
for _, source := range Config.Server.Sources {
originalPaths[source] = source.Path
}
setupSources(true)
setupUrls()
setupMedia(true)
setupMedia()
setupFrontend(true)
// Restore original paths so the YAML output has the correct paths, not the placeholder
for _, source := range Config.Server.Sources {
if originalPath, ok := originalPaths[source]; ok {
source.Path = originalPath
}
}
output := "../frontend/public/config.generated.yaml" // "output YAML file"
// Generate YAML with comments enabled, full config, and deprecated fields filtered
// Use multi-directory comment parsing to get comments from all packages
yamlContent, err := GenerateConfigYamlWithSource(&Config, true, true, true, "pkg/settings")
yamlContent, err := GenerateConfigYamlWithSource(&Config, true, true, true, "common/settings")
if err != nil {
fmt.Fprintf(os.Stderr, "error generating YAML: %v\n", err)
os.Exit(1)
@@ -961,9 +758,9 @@ func GenerateYaml() {
func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filterDeprecated bool) (string, error) {
// Try different source paths to handle both runtime and test scenarios
sourcePaths := []string{
".", // When running tests from settings directory
"../pkg/settings", // When running from backend directory
"pkg/settings", // Alternative path
".", // When running tests from settings directory
"../common/settings", // When running from backend directory
"common/settings", // Alternative path
}
for _, sourcePath := range sourcePaths {
@@ -1053,7 +850,7 @@ func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFul
// identifySecretFieldsByReflection identifies secret fields by known field names
func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets SecretFieldsMap) {
if v.Kind() == reflect.Pointer {
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return
}
@@ -1088,9 +885,9 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
// Recursively check nested structs
fieldValue := v.Field(i)
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
nestedTypeName := field.Type.Name()
if field.Type.Kind() == reflect.Pointer {
if field.Type.Kind() == reflect.Ptr {
nestedTypeName = field.Type.Elem().Name()
}
identifySecretFieldsByReflection(fieldValue, nestedTypeName, secrets)
@@ -1100,7 +897,7 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
// identifyDeprecatedFieldsByReflection identifies deprecated fields by known field names
func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, deprecated DeprecatedFieldsMap) {
if v.Kind() == reflect.Pointer {
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return
}
@@ -1130,9 +927,9 @@ func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, depr
// Recursively check nested structs
fieldValue := v.Field(i)
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
nestedTypeName := field.Type.Name()
if field.Type.Kind() == reflect.Pointer {
if field.Type.Kind() == reflect.Ptr {
nestedTypeName = field.Type.Elem().Name()
}
identifyDeprecatedFieldsByReflection(fieldValue, nestedTypeName, deprecated)
@@ -1166,7 +963,7 @@ func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, e
}
if err != nil || readErr != nil {
// Fallback to setDefaults
defaultConfigValue := SetDefaults(true)
defaultConfigValue := setDefaults(true)
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
defaultConfig = &defaultConfigValue
}
@@ -1259,7 +1056,7 @@ func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull
}
if err != nil || readErr != nil {
// Fallback to setDefaults
defaultConfigValue := SetDefaults(true)
defaultConfigValue := setDefaults(true)
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
defaultConfig = &defaultConfigValue
}
@@ -1339,3 +1136,53 @@ func AlignComments(input string) string {
return strings.Join(outputLines, "\n")
}
// isImagePreviewMap checks if the given map is an ImagePreview map
func isImagePreviewMap(v reflect.Value) bool {
if v.Kind() != reflect.Map {
return false
}
// Check if the map key type is ImagePreviewType
keyType := v.Type().Key()
return keyType.Name() == "ImagePreviewType"
}
// buildImagePreviewMap builds a YAML node for ImagePreview maps, showing all supported types
func buildImagePreviewMap(v reflect.Value, comm CommentsMap, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Get all supported image preview types from the global slice
for _, imageType := range AllImagePreviewTypes {
typeName := string(imageType)
// Create key node
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: typeName,
}
// Get the value from the map, defaulting to false if not present
var value bool
if v.Len() > 0 {
// Look for the key in the map
for _, mapKey := range v.MapKeys() {
if mapKey.String() == typeName {
value = v.MapIndex(mapKey).Bool()
break
}
}
}
// Create value node
valNode := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!bool",
Value: fmt.Sprintf("%t", value),
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
return mapNode, nil
}
Loaded 100 of 878 files, more files were not shown because too many files have changed in this diff. Show more