mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 05:01:59 -05:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5e3ec040a | ||
|
|
6ec27c16f7 |
No files matched your search
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "filebrowser",
|
||||
"image": "mcr.microsoft.com/devcontainers/go:1.25-bookworm",
|
||||
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "22"
|
||||
},
|
||||
"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
@@ -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/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -20,8 +17,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
|
||||
@@ -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,13 +28,11 @@ 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
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
@@ -42,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
|
||||
@@ -63,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:
|
||||
@@ -82,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
|
||||
@@ -105,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
|
||||
@@ -126,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
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Promote Beta to Stable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to promote (format: 0.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
promote-beta-to-stable:
|
||||
name: Promote Beta to Stable
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }} # Uses Personal Access Token for branch operations
|
||||
|
||||
- name: Validate version format
|
||||
id: validate_version
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "❌ ERROR: Version must be in '0.0.0' format."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Version format validated: $VERSION"
|
||||
|
||||
- name: Check if beta branch exists
|
||||
id: check_beta_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
|
||||
if ! git ls-remote --exit-code origin "$BETA_BRANCH"; then
|
||||
echo "❌ ERROR: Beta branch '$BETA_BRANCH' does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Beta branch '$BETA_BRANCH' exists."
|
||||
|
||||
- name: Check if stable branch already exists
|
||||
id: check_stable_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
if git ls-remote --exit-code origin "$STABLE_BRANCH"; then
|
||||
echo "❌ ERROR: Stable branch '$STABLE_BRANCH' already exists!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Stable branch '$STABLE_BRANCH' does not exist."
|
||||
|
||||
- name: Create and push new stable branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
# Fetch latest changes
|
||||
git fetch origin "$BETA_BRANCH"
|
||||
|
||||
# Create new stable branch from beta branch
|
||||
git checkout -b "$STABLE_BRANCH" origin/"$BETA_BRANCH"
|
||||
|
||||
# Push new stable branch to remote
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully created stable branch '$STABLE_BRANCH' from '$BETA_BRANCH'."
|
||||
|
||||
- name: Merge beta into stable
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
# Checkout stable branch
|
||||
git checkout "$STABLE_BRANCH"
|
||||
|
||||
# Merge beta into stable
|
||||
git merge --no-ff "origin/$BETA_BRANCH" -m "Merge $BETA_BRANCH into $STABLE_BRANCH"
|
||||
|
||||
# Push merge changes
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
|
||||
@@ -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
|
||||
|
||||
@@ -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,47 +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
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- 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
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- 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
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- 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
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run icons:check
|
||||
run: npm i && npm run i18n:check
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,66 @@
|
||||
name: stable release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "stable/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright - regular
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
create_release_tag:
|
||||
needs: [ test_playwright ]
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: |
|
||||
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
|
||||
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
|
||||
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
id: extract_branch
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
@@ -11,14 +11,12 @@ 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
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Install dependencies and build frontend
|
||||
run: npm i && npm run build
|
||||
working-directory: frontend
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
-601
@@ -2,607 +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.7
|
||||
|
||||
|
||||
**New Features**:
|
||||
- Add cli init CLI command (#2957)
|
||||
|
||||
**Notes**:
|
||||
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
|
||||
- Added risc-v to official releases
|
||||
|
||||
**Bugfixes**:
|
||||
- Hide the Replace option on upload/create conflict prompts when the user lacks modify permission (or when a public share disallows replacements), so create-only users are not offered an action that the server rejects (#2837)
|
||||
- Preserve Ctrl-click file selection when keyboard state is stale (#2958) (#2923)
|
||||
- Avoid false stalls during parallel transfers (#2950) (#2948) thanks @gudcks0305
|
||||
- Cap source usage-bar percentage at 100% when indexed size exceeds partition total (#2761) (#2238)
|
||||
- On Linux, source partition totals sum distinct filesystems mounted under the source root (nested mounts) so usage bars match indexed content (#2761)
|
||||
|
||||
## v2.0.6
|
||||
|
||||
**Security**:
|
||||
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
|
||||
|
||||
**Notes**:
|
||||
- renaming sources updates sidebar links (#2878)
|
||||
- disabling/deleting a user source removes that source from sidebar links (#2942)
|
||||
|
||||
**Bugfixes**:
|
||||
- Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2894)
|
||||
- Support non-ASCII share passwords (#2933)
|
||||
- Fall back to buffered copies when FUSE rejects fast paths (#2938) (#2924)
|
||||
- Preserve deleted sidebar links across restarts (#2935)
|
||||
|
||||
## v2.0.5
|
||||
|
||||
**New Features**:
|
||||
- Users can set default view mode and thumbnail size from Profile settings (Listing options). Admins can set the same defaults for existing users from the user edit prompt. Values persist to the account and apply on new browsers or devices; they do not override view preferences already saved locally ([#2884](https://github.com/gtsteffaniak/filebrowser/issues/2884)).
|
||||
- Admins can edit all profile preference defaults for existing users from the user management panel (listing, preview, sidebar, theme, and related options), matching what was previously available only for new users and global user defaults.
|
||||
|
||||
**Security**:
|
||||
- [Moderate] public metadata api returns file content to anonymous share visitors, ignoring the share's download limit and file-viewer setting (GHSA-55mw-cwg7-m8f5) -- thanks @kta1kri
|
||||
|
||||
**Bugfixes**:
|
||||
- Fixed infinite loading spinner on `/` and `/login` after CSP security hardening ([#2886](https://github.com/gtsteffaniak/filebrowser/issues/2886), [#2890](https://github.com/gtsteffaniak/filebrowser/issues/2890))
|
||||
- fixed darkmode styling bug where background wouldn't respect configured settings when not logged in
|
||||
- fixed styling bug where padding was missing from right side of listing view.
|
||||
- tilde paths for sources were not properly expanded. (eg "~" for home)
|
||||
- fixed tooltip not showing up for some circumstances on mobile, improved consistency.
|
||||
- a few styling fixes for inconsistencies (#2908)
|
||||
- Disk-usage widget no longer reports PB/TB-scale garbage on Docker Desktop for Mac (virtiofs) bind mounts; space is computed from the filesystem fragment size (`f_frsize`) instead of the optimal-transfer block size (`f_bsize`) on Linux ([#2894](https://github.com/gtsteffaniak/filebrowser/issues/2894)).
|
||||
|
||||
## v2.0.4
|
||||
|
||||
**Bugfixes**:
|
||||
- OnlyOffice editor failed to load after the CSP security fix; allow configured `integrations.office.url` and `integrations.office.internalUrl` origins in `script-src` ([#2874](https://github.com/gtsteffaniak/filebrowser/issues/2874)).
|
||||
|
||||
## v2.0.3
|
||||
|
||||
**Security**:
|
||||
- [High] Stored XSS via HTML preview: strip `<script>` from sanitized srcdoc, remove `allow-same-origin` from the preview iframe sandbox, set HttpOnly on the session cookie, and add a nonce-based `script-src` CSP on the SPA shell (GHSA-vvm6-jwrf-hgmg) -- thanks @qrn12580
|
||||
- [Moderate] Non-admin users could PATCH privileged fields on their own account (scopes, permissions, etc.), allowing privilege escalation; restores v1 non-admin field guard on PATCH /api/users (GHSA-p5cc-4p84-c4m2) -- thanks @Chri6s
|
||||
|
||||
**New Features**:
|
||||
- Added option to globally disable the "Install App" message via `frontend.disablePWAInstall`
|
||||
- PWA improvements for installed mobile apps: dedicated maskable icons (192/512), manifest and splash colors that follow the instance default theme, runtime `theme-color` sync on dark-mode toggle, and edge-to-edge safe-area layout for notched devices (#2625) (#2869) -- thanks @APatenaude
|
||||
|
||||
**Bugfixes**:
|
||||
- Fixed Fuji `.raf` thumbnail preview by extracting the camera-embedded JPEG from the RAF header (regression for files where TIFF-based raw extraction does not apply).
|
||||
- Image previews for unsupported decodable formats now return HTTP 415 immediately instead of attempting JPEG decode and returning HTTP 500.
|
||||
- OIDC: preserve the verified ID-token user identifier when falling back to the UserInfo endpoint for missing groups, so login no longer fails with HTTP 500 when UserInfo returns groups but omits the configured identifier.
|
||||
- LDAP: restore `memberOf` as the default `groupsClaim` when unset, fixing group-based admin and login authorization for existing LDAP configs after the v2.0.2 default changed to `groups`.
|
||||
- "Install app" message reappears after being cleared on device.
|
||||
- Restored upload chunk size `0` to disable chunking as documented ([#2202](https://github.com/gtsteffaniak/filebrowser/issues/2202)).
|
||||
- Fixed iOS 26 / WebKit multi-chunk upload stall by isolating chunk connections and returning partial chunk JSON responses ([#2734](https://github.com/gtsteffaniak/filebrowser/issues/2734)).
|
||||
- Sidebar folder links with custom names, icons, or styles no longer disappear after restart; multiple shortcuts to different folders on the same source are preserved ([#2809](https://github.com/gtsteffaniak/filebrowser/issues/2809)).
|
||||
- Adding a source to a user via scopes now auto-adds a matching sidebar link; removing a source keeps the link (shown disabled) so users can delete it manually.
|
||||
- A password reset via CLI returns user to a password method user.
|
||||
- Authentication login methods are consistently enforced without modifying existing account settings.
|
||||
|
||||
## v2.0.2
|
||||
|
||||
**Bugfixes**:
|
||||
- OIDC: `groupsClaim` value is always included in requested scopes and falls back to the UserInfo endpoint when the ID token omits the groups claim.
|
||||
- Windows: fix backslash duplication in navigation URLs, folder sizes showing 4 KB, and download/preview failures (#2815, #2816)
|
||||
- Wrong extension in the 'new database was created popup (#2817) (#2824)
|
||||
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
|
||||
- OnlyOffice is inaccessible on password-protected shares (#2811)
|
||||
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
|
||||
|
||||
## v2.0.1
|
||||
|
||||
**Security**:
|
||||
- [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds
|
||||
- [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo
|
||||
- [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu)
|
||||
- [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec
|
||||
- [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209
|
||||
|
||||
**Notes**:
|
||||
- GroupMap `SyncUserGroups` fix so JWT/OIDC/LDAP group memberships survive restart (#2742).
|
||||
- Session renew is handled by client keep-alive. removed per-request `X-Renew-Token` header handling.
|
||||
- Improvements to document thumbnail generation performance.
|
||||
- behavior changes for typing to select files in listing view.
|
||||
- next/previous buttons don't hide automatically on photos (#2767)
|
||||
- added more actions available in advanced search (#2776)
|
||||
|
||||
**Bugfixes**:
|
||||
- GroupMap mutations (`SyncUserGroups`, add/remove group members) are write-through to SQL so JWT/OIDC/LDAP group memberships survive restart (#2742).
|
||||
- `defaultEnabled` behavior change/fix so defaultEnabled means its always added to users on startup and login. (updated docs too)
|
||||
- Long uploads/downloads no longer lose the session mid-transfer: proactive session JWT keep-alive renews before expiry (#2638).
|
||||
- Disable auto logout/session expiry if there are active file transfers going (#2638)
|
||||
- upload options get cut off on mobile (#2685)
|
||||
- Uploading image: image gets cut-off (#2765)
|
||||
- Process aborts (not just 500) during PDF thumbnail generation (#2763)
|
||||
- PDF preview generation prevents file uploads from completing (#2752)
|
||||
- Brings back double-tap to zoom images that was mistakenly removed.
|
||||
- multiple embedded subtitles with same language bug (#2756)
|
||||
- gallery view download button missing (#2767)
|
||||
- fixed HEIC rotation regression from v1.5.x
|
||||
- fixed raw image preview regression from v1.5.x
|
||||
- html viewer takes full height
|
||||
- Members without download permission receive 403 when opening text-based files despite OnlyOffice preview being enabled (#2777)
|
||||
|
||||
## v2.0.0
|
||||
|
||||
This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary, and the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert legacy config.
|
||||
|
||||
**Breaking Changes**:
|
||||
- Removed: `GET /api/raw` and `GET /public/api/raw` download routes — use `/api/resources/download` instead.
|
||||
- Removed: `/share/…` URL redirect to `/public/share/…` — use `/public/share/…` directly.
|
||||
- Removed: singular `source` search api param (use `sources`), bare `scope` paths without `sourceName:` prefix, and `glob` / `useGlob` aliases (use `useWildcard`).
|
||||
- Removed `config.conditionals`, source-level `indexingIntervalMinutes` (indexing always uses adaptive scheduling), and deprecated rule fields `fileNames` / `folderNames` / top-level `hidden` — use `config.rules` with `fileName`, `folderName`, and `ignoreHidden` on rules. See [Exclusion rules](https://filebrowserquantum.com/en/docs/user-guides/general-configuration/exclusion-rules/).
|
||||
- Removed: deprecated `userDefaults` config formats (nested and flat) — use the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert before upgrading.
|
||||
- Changed: `PUT /api/users` moved to the more appropriate `PATCH` method and requires specifying `which` in the body. Blank or `all` values are rejected.
|
||||
- Changed: http related config options in `server` config key moved to `http` config key. See [HTTP settings](https://filebrowserquantum.com/en/docs/configuration/http/).
|
||||
- Changed (reverse proxy): `http.trustedHeaders` (v1.5.x list) removed — use `http.trustProxyHeaders: true` when behind nginx, Traefik, or Caddy. When enabled, FileBrowser honors `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-For`, and `X-Real-IP` for client IP, cookies, OIDC callbacks, WebAuthn, share URLs, rate limiting, and activity logs. Default is `false` (direct connection values). The [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) converts v1 `trustedHeaders` lists to `trustProxyHeaders: true`. See [Reverse proxy](https://filebrowserquantum.com/en/docs/getting-started/reverse-proxy/) and [HTTP trustProxyHeaders](https://filebrowserquantum.com/en/docs/configuration/http/#trustproxyheaders).
|
||||
- Changed: `FILEBROWSER_DATABASE` environment variable — use `FILEBROWSER_DATABASE_PATH` instead. See [Environment variables](https://filebrowserquantum.com/en/docs/reference/environment-variables/) and [Server settings](https://filebrowserquantum.com/en/docs/configuration/server/).
|
||||
- Changed: Moved stream api to `/api/media/stream`. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
|
||||
- Changed: CLI user management — canonical commands are `user set <username> --password [value]` and `user promote <username>`; `set -u username,password` is deprecated. See [CLI reference](https://filebrowserquantum.com/en/docs/reference/cli/).
|
||||
|
||||
|
||||
**New Features**:
|
||||
- View grant mechanism to distinguish between UI viewing and download. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
|
||||
- granular per-source file permissions (view, download, modify, create, delete) with automatic migration from global permissions
|
||||
- per-source defaults configurable in `settings > access management`
|
||||
- `view` permission is automatically set to true unless explicitly set to false. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
|
||||
- can be enforced for all users. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
|
||||
- New activity logs for user activity.
|
||||
- charts and historical data
|
||||
- export to csv reports
|
||||
- Media player improvements:
|
||||
- Refreshed playback queue UI: Supports thumbnails, stored into session storage, and has a "clear queue" button (#2575) (#2600).
|
||||
- Loop now has 3 states (off/single/all) and neither of them will clear the existing queue (#2600).
|
||||
- New "Audio visualizer" for audio files (desktop-only), you can configure some basic things to your taste (#2575) (#2620).
|
||||
- The current state of the audio panel now is stored into local storage.
|
||||
- More gestures: Swipe up to enter/exit fullscreen, long-press to change playback speed, single tap to pause (#2575).
|
||||
- Videos now will resume fullscreen and PiP when navigating (queue auto-navigation, swipes gestures or next/previous) (#2649).
|
||||
- Added `F4` shortcut to refresh the current directory and metadata (#2600).
|
||||
- opt-in feature to send deployment analytics to filebrowser quantum developer servers
|
||||
- anonymized with a viewer so users can see what info would be sent.
|
||||
- if opt-in, every month a snapshot of your deployment config would be sent to developer servers
|
||||
- this will help me know what features are being used and what versions everyone is on over time. I will also provide a public dashboard with this information in the future.
|
||||
- WebDAV now supports set modification time via the `X-OC-Mtime` header for clients that support it (#2626). See [WebDAV docs](https://filebrowserquantum.com/en/docs/features/webdav/).
|
||||
- Copy operations now preserve their original modification times (#2642) (#2647):
|
||||
- WebUI preserves both, files and directories.
|
||||
- WebDAV `COPY` preserves modification times only for files, is limitation we have with webdav.
|
||||
- User default enhancements
|
||||
- Config `userDefaults` seeds SQLite on first run; only fields explicitly set in config stay locked in **Settings → User defaults** (other defaults remain editable)
|
||||
- Added administrator controls for universal user defaults and enforced preferences in `settings > user management > user defaults`.
|
||||
- Added configurable default file permissions per source in `settings > access management`.
|
||||
- Added a User Defaults editor for account, permission, and profile preferences in the edit/create user prompt. See [User management](https://filebrowserquantum.com/en/docs/configuration/users/).
|
||||
- Database env var rename: `FILEBROWSER_DATABASE` is removed (startup fails if set). Use `FILEBROWSER_DATABASE_PATH` (default `filebrowser.sqlite`) or `server.database.path` in config. See [Environment variables](https://filebrowserquantum.com/en/docs/reference/environment-variables/) and [Server settings](https://filebrowserquantum.com/en/docs/configuration/server/).
|
||||
- CLI: `user set` with `--password` (inline value, interactive prompt on TTY, or piped stdin); `user promote` for admin grant without password reset. See [CLI reference](https://filebrowserquantum.com/en/docs/reference/cli/).
|
||||
|
||||
**Notes**:
|
||||
- v2.x.x uses a new write-through backend state management. Changes go through a fast memory layer and also write changes to database to stay in sync. See [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/).
|
||||
- CLI server start (`./filebrowser`), `setup`, `version`, and `set rule` syntax unchanged; see [CLI docs](https://filebrowserquantum.com/en/docs/reference/cli/)
|
||||
- new dropdown and input styles
|
||||
- swipe gestures to dismiss notifications (#2672)
|
||||
- user updates are more granular, don't include entire user payload.
|
||||
- `user.id` has been moved to a backend property and all frontend apis now query users by username. Swagger has been updated. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
|
||||
- removed legacy and deprecated properties from API responses and generated config output
|
||||
- `/api/media/stream` is audio/video only (range-based chunking). Non-media inline viewing uses `GET /api/resources/view`. Both endpoints use the same `viewToken` from file metadata. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
|
||||
- removed exiftool as an optional helper, always built with the supported libraries (requires 64 bit os)
|
||||
- If migration issues arise, see [Migration troubleshooting](https://filebrowserquantum.com/en/docs/getting-started/migration/troubleshooting/).
|
||||
- default browser media player option removed, always uses themed plyr
|
||||
- [docker] upgraded ffmpeg from 8.1.2 to 9.0
|
||||
|
||||
## v1.5.5
|
||||
|
||||
**BugFixes**:
|
||||
- fix uncustomized (minimal) API tokens creation needed by webdav clients (#2503)
|
||||
|
||||
## v1.5.4
|
||||
|
||||
**Notes**:
|
||||
- PWA installation name now capped at 30 characters instead of 12 (#2699)
|
||||
|
||||
**BugFixes**:
|
||||
- fix forward slash blocked in text fields when the search shortcut listener intercepts `/` (#2696)
|
||||
|
||||
## v1.5.3
|
||||
|
||||
**BugFixes**:
|
||||
- fix probe canShare with the real file, not a fixed text/plain stand-in (#2664)
|
||||
- When the logout button is pressed, the user is redirected to an invalid URL that does not honor baseURL / externalUrl (#2657)
|
||||
- fix redirect to login when an authenticated request returns 401 (expired session)
|
||||
|
||||
## v1.5.2
|
||||
|
||||
**Notes**:
|
||||
- [docker] upgraded ffmpeg version 8.1.1 to 8.1.2
|
||||
|
||||
**BugFixes**:
|
||||
- Fixed OnlyOffice reopening an older editor state after a document is modified (#2633) (#2578).
|
||||
- Cannot "Reset and generate new two-factor code" to reset the TOTP for a user (#2399) (#2641).
|
||||
|
||||
## v1.5.1
|
||||
|
||||
**BugFixes**:
|
||||
- fix http config section being dropped so trustedHeaders and disableRateLimit apply (#2602) (#2560)
|
||||
- fix upload shares with password (#2589) (#2573)
|
||||
- fix token when returning from preview on shares with pass (#2588) (#2465)
|
||||
- fix share undefined url after editing (#2567) (#2523)
|
||||
## v1.5.0
|
||||
|
||||
**New Features**:
|
||||
- Added basic html viewer with relative reference support (#2522)
|
||||
- Shares can have pinned files, requires a user to have edit access to share (#2522)
|
||||
- Enhanced search
|
||||
- now uses "lazy" match by default. (#2509)
|
||||
- added missing `case sensitive` option in the UI
|
||||
- Progressive Web App (PWA) improvements
|
||||
- restored install prompt with sidebar install button (#2086)
|
||||
- camera and video capture buttons on upload (mobile-friendly `capture` inputs)
|
||||
- send files to other apps via the Web Share API (`Send to app` in the context menu)
|
||||
- App notifications for file operations (#2478)
|
||||
- optional browser notifications when uploads, chunked downloads, move/copy, or failures finish while the tab is in the background
|
||||
- single on/off toggle in Notifications settings, stored in browser local storage
|
||||
|
||||
**Notes**:
|
||||
- [docker] upgraded ffmpeg version 8.1 to 8.1.1
|
||||
|
||||
**BugFixes**:
|
||||
- fixed PWA manifest `scope` and `id` so install works when the app is served under a base URL
|
||||
- installing a public share as a PWA now opens the share URL instead of the site root (#2302)
|
||||
|
||||
## v1.4.4
|
||||
|
||||
**BugFixes**:
|
||||
- Pinning/Unpinning file resets users' scope permission (#2532)
|
||||
|
||||
## v1.4.3
|
||||
|
||||
**Security**:
|
||||
- [High] Path traversal in subtitle handler allows any authenticated user to read arbitrary files (GHSA-vvp7-h4fj-m28w)
|
||||
|
||||
**New Features**:
|
||||
- pinned files/folders (#2396) (#2510)
|
||||
- Markdown Image relative reference support (#2355)
|
||||
- Add an option to create a new folder for unarchiving (#2338)
|
||||
|
||||
**Notes**:
|
||||
- added fallback to show text in notification if copy fails (#2517)
|
||||
- added `alt`+`arrow up` shortcut as alias of `backspace` to go into parent directory (#2501) (#2521)
|
||||
- added `alt`+`arrow down` shortcut as alias of `enter` to open files in Listing View (#2501) (#2521)
|
||||
- updated help menu with better translations
|
||||
- migrate i18n to v11 (#2472) (#2504)
|
||||
- migrate eslint (#2459)
|
||||
- improved preview cancellation to improve performance when navigating UI.
|
||||
|
||||
**BugFixes**:
|
||||
- fix lyrics regex parsing (#2505)
|
||||
- fix folder previews issue (#2487) (#2492)
|
||||
- fix accidental exit on images while using gestures (#2508)
|
||||
- navigating using the up/down arrow keys in Listing View was "jumping randomly" in some view modes (#2521)
|
||||
- socket field in config.yaml is ignored (#2497)
|
||||
- fix keep opened file selected after closing its preview @anpryl (#2515)
|
||||
|
||||
|
||||
## v1.4.2
|
||||
|
||||
**Security**:
|
||||
- [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**:
|
||||
|
||||
+70
-9
@@ -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 important on Windows -- needed for bash support.
|
||||
- **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`
|
||||
@@ -1,30 +1,31 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://app.codacy.com/gh/gtsteffaniak/filebrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||
[](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
|
||||
[](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://hub.docker.com/r/gtstef/filebrowser)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
[](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,47 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
|
||||
has standalone binary | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
price | free | free | free | free tier | free tier | $99+ |
|
||||
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Advanced Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Indexed Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
|
||||
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
|
||||
Custom job support | :construction: | ✅ | ❌ | ✅ | ❌ | ✅ |
|
||||
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
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: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
*Accessing files from FileBrowser via WebDav is a newly introduced and experimental feature.
|
||||
+15
-14
@@ -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}'" \
|
||||
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/package-lock.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 ci --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_DATABASE="/home/filebrowser/data/database.db"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" ]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+12
-11
@@ -3,25 +3,25 @@ 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
|
||||
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/package-lock.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 ci
|
||||
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_DATABASE="/home/filebrowser/data/database.db"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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,66 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig<PlaywrightFixtureOptions>({
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -49,29 +42,11 @@ builds:
|
||||
post:
|
||||
- upx {{ .Path }}
|
||||
|
||||
# Build configuration for linux riscv64
|
||||
- id: linux-riscv64
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
env:
|
||||
- CC=zig cc -target riscv64-linux-musl
|
||||
# go-fitz does not provide riscv64 MuPDF libraries yet: https://github.com/gen2brain/go-fitz/tree/master/libs
|
||||
tags:
|
||||
- analytics
|
||||
- musl
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- riscv64
|
||||
|
||||
# Build configuration for linux
|
||||
- id: linux-arm32
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
@@ -87,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
|
||||
@@ -108,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:
|
||||
|
||||
File renamed without changes.
+1
-1
@@ -11,7 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+9
-337
@@ -1,22 +1,18 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/internal/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"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) {
|
||||
@@ -90,111 +86,6 @@ func Test_GetRealPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: PUT / save must not os.Chmod to PermFile for existing files, or +x (and other mode bits) are lost (#2309).
|
||||
func TestWriteFilePreservesExecutableBit(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
originalCache := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
|
||||
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_writefile_exec", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
scriptName := "backupscript.sh"
|
||||
scriptAbs := filepath.Join(root, scriptName)
|
||||
if err := os.WriteFile(scriptAbs, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName := "test_writefile_exec"
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: sourceName,
|
||||
Path: root,
|
||||
}, false, false)
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
stopPoll := startScannerStatusPoll(t, idx, 1)
|
||||
defer stopPoll()
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
indexPath := "/" + scriptName
|
||||
if err := WriteFile(sourceName, indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
st, err := os.Stat(scriptAbs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Mode()&0o111 == 0 {
|
||||
t.Fatalf("executable bits cleared after save: mode=%v", st.Mode())
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
}
|
||||
|
||||
func TestWriteFileRejectsExistingDirectory(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
originalCache := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
|
||||
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_writefile_isdir", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
dirName := "folder"
|
||||
if err := os.Mkdir(filepath.Join(root, dirName), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceName := "test_writefile_isdir"
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: sourceName,
|
||||
Path: root,
|
||||
}, false, false)
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
stopPoll := startScannerStatusPoll(t, idx, 1)
|
||||
defer stopPoll()
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
err := WriteFile(sourceName, "/"+dirName, strings.NewReader("file content"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when path is an existing directory")
|
||||
}
|
||||
if !errors.Is(err, liberrors.ErrIsDirectory) {
|
||||
t.Fatalf("expected ErrIsDirectory, got: %v", err)
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
}
|
||||
|
||||
func TestSortItems(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -357,7 +248,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
{Name: "Test Object", Type: "directory"},
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
|
||||
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)
|
||||
@@ -375,7 +266,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
|
||||
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)
|
||||
@@ -467,7 +358,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
|
||||
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)
|
||||
@@ -483,7 +374,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
Type: "directory",
|
||||
},
|
||||
}
|
||||
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
|
||||
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)
|
||||
@@ -518,222 +409,3 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
t.Error("Directory 'Test Object' should have been added to Folders")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFilesRootProtection tests that DeleteFiles refuses to delete
|
||||
// the source root directory itself, preventing catastrophic data loss.
|
||||
// This is a regression test for the safety guard added to prevent accidental
|
||||
// deletion of the entire source directory.
|
||||
func TestDeleteFilesRootProtection(t *testing.T) {
|
||||
// Use a temporary directory for cache
|
||||
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
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
// Initialize the database
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_root_protection", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
// Create a real temporary directory to use as source root
|
||||
realTmpDir := t.TempDir()
|
||||
|
||||
// Initialize the index with a real path
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test_root_protection",
|
||||
Path: realTmpDir,
|
||||
}, false, false) // false for mock mode (real path), false for isNewDb
|
||||
|
||||
// Wait for scanner to be ready
|
||||
idx := indexing.GetIndex("test_root_protection")
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Wait for scanner to finish
|
||||
for i := 0; i < 50; i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
status := idx.GetScannerStatus()
|
||||
if status["status"] == "ready" || status["status"] == "unavailable" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Attempting to delete the root directory should return an error
|
||||
err := DeleteFiles("test_root_protection", realTmpDir, true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error when trying to delete root directory")
|
||||
}
|
||||
|
||||
// Verify the error message contains expected text
|
||||
expectedErrMsg := "refusing to delete source root directory"
|
||||
if err != nil && !strings.Contains(err.Error(), expectedErrMsg) {
|
||||
t.Errorf("Expected error message to contain '%s', got: %v", expectedErrMsg, err.Error())
|
||||
}
|
||||
|
||||
// Test with trailing slash - should still be blocked
|
||||
err = DeleteFiles("test_root_protection", realTmpDir+"/", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error for root directory with trailing slash")
|
||||
}
|
||||
|
||||
// Test with cleaned path variations
|
||||
err = DeleteFiles("test_root_protection", realTmpDir+"/.", true)
|
||||
if err == nil {
|
||||
t.Error("DeleteFiles should return an error for root directory with /. suffix")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteFilesSubfolderWithRootName tests that deleting a subfolder
|
||||
// with the same name as the root directory works correctly and doesn't
|
||||
// accidentally delete the root.
|
||||
// Regression test for path handling bug where /srv/srv could be mistaken for /srv.
|
||||
func TestDeleteFilesSubfolderWithRootName(t *testing.T) {
|
||||
iters := raceStressIterations
|
||||
if testing.Short() {
|
||||
iters = 1
|
||||
}
|
||||
for iter := 1; iter <= iters; iter++ {
|
||||
iter := iter
|
||||
t.Run(fmt.Sprintf("stress-iter-%d", iter), func(t *testing.T) {
|
||||
runDeleteFilesSubfolderWithRaceStress(t, iter)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
raceStressIterations = 3
|
||||
raceStatusPollInterval = 5 * time.Millisecond
|
||||
waitStatusMaxTries = 100
|
||||
waitStatusSleep = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
|
||||
t.Helper()
|
||||
start := time.Now()
|
||||
t.Logf("iteration %d: start", iter)
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
originalCache := settings.Config.Server.CacheDir
|
||||
settings.Config.Server.CacheDir = cacheDir
|
||||
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
|
||||
|
||||
if fileutils.PermDir == 0 {
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
}
|
||||
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, _, err := dbsql.NewIndexDB("test_subfolder_rootname", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
indexing.SetIndexDBForTesting(db)
|
||||
}
|
||||
|
||||
realTmpDir := t.TempDir()
|
||||
subfolderName := filepath.Base(realTmpDir)
|
||||
subfolderPath := filepath.Join(realTmpDir, subfolderName)
|
||||
|
||||
if err := os.Mkdir(subfolderPath, 0755); err != nil {
|
||||
t.Fatalf("Failed to create subfolder: %v", err)
|
||||
}
|
||||
|
||||
testFile := filepath.Join(subfolderPath, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
sourceName := fmt.Sprintf("test_subfolder_rootname-%d", iter)
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: sourceName,
|
||||
Path: realTmpDir,
|
||||
}, false, false)
|
||||
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatalf("Failed to get test index for %s", sourceName)
|
||||
}
|
||||
|
||||
stopPolling := startScannerStatusPoll(t, idx, iter)
|
||||
defer stopPolling()
|
||||
|
||||
waitForScannerReady(t, idx)
|
||||
|
||||
err := DeleteFiles(sourceName, subfolderPath, true)
|
||||
stopPolling()
|
||||
|
||||
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteFiles should succeed for subfolder with root-like name, got error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(subfolderPath); !os.IsNotExist(err) {
|
||||
t.Fatal("Subfolder should have been deleted")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(realTmpDir); os.IsNotExist(err) {
|
||||
t.Fatal("Root directory should NOT have been deleted")
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
// Give scanners time to finish their cleanup (defer blocks in executeScan)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
t.Logf("iteration %d: cleaned scanners and indices", iter)
|
||||
}
|
||||
|
||||
func waitForScannerReady(t *testing.T, idx *indexing.Index) {
|
||||
t.Helper()
|
||||
// Give the scanners a moment to initialize, but don't wait for full "ready" status
|
||||
// The initial scan can take longer than the test timeout, but DeleteFiles will work
|
||||
// as long as the scanners are initialized and running
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify scanners exist
|
||||
status := idx.GetScannerStatus()
|
||||
if totalScanners, ok := status["totalScanners"].(int); !ok || totalScanners == 0 {
|
||||
t.Fatal("No scanners were created")
|
||||
}
|
||||
t.Log("Scanners initialized, proceeding with delete operation")
|
||||
}
|
||||
|
||||
func startScannerStatusPoll(t *testing.T, idx *indexing.Index, iter int) func() {
|
||||
t.Helper()
|
||||
stop := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(raceStatusPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
close(done)
|
||||
return
|
||||
case <-ticker.C:
|
||||
idx.GetScannerStatus()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
close(stop)
|
||||
<-done
|
||||
t.Logf("iteration %d: poll watcher stopped", iter)
|
||||
})
|
||||
}
|
||||
}
|
||||
+163
-448
@@ -1,67 +1,30 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
var reDuration = regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
|
||||
|
||||
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
|
||||
var CheckPermissionsFunc = func(opts utils.FileOptions, user *users.User) (string, string, error) {
|
||||
return checkPermissionsImpl(opts, user, svc())
|
||||
}
|
||||
|
||||
// addMetadataToChildren runs concurrent ffmpeg/tag extraction for audio/video children.
|
||||
// Each goroutine is registered on wg. Caller must invoke wg.Wait() after scheduling.
|
||||
func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileOptions, wg *sync.WaitGroup) {
|
||||
sharedFFmpegService := ffmpeg.Get()
|
||||
if sharedFFmpegService == nil {
|
||||
return
|
||||
}
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
if !isItemAudio && !isItemVideo {
|
||||
continue
|
||||
}
|
||||
isAudio := isItemAudio
|
||||
wg.Go(func() {
|
||||
fullPath := response.RealPath + "/" + fileItem.Name
|
||||
if isAudio {
|
||||
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, false, sharedFFmpegService); err != nil {
|
||||
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
}
|
||||
} else {
|
||||
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, sharedFFmpegService); err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// processDirectoryMetadata extracts metadata for audio/video files in directories
|
||||
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
|
||||
metadataCount := 0
|
||||
@@ -73,23 +36,71 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
metadataCount++
|
||||
}
|
||||
}
|
||||
// Process files concurrently using goroutines
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // Protects processedCount
|
||||
|
||||
// Refresh directory to update folder sizes
|
||||
wg.Go(func() {
|
||||
indexPath := idx.MakeIndexPath(response.Path, true).String()
|
||||
indexPath := idx.MakeIndexPath(response.Path, true)
|
||||
err := idx.RefreshDirectory(indexPath, false)
|
||||
if err != nil {
|
||||
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if opts.Metadata && metadataCount > 0 {
|
||||
addMetadataToChildren(response, opts, &wg)
|
||||
// Set hasMetadata flag if there are files with potential metadata
|
||||
if metadataCount > 0 {
|
||||
response.HasMetadata = true
|
||||
}
|
||||
|
||||
// Only process metadata if explicitly requested
|
||||
if opts.Metadata && metadataCount > 0 {
|
||||
processedCount := 0
|
||||
|
||||
// Create a single shared FFmpegService instance for all files to coordinate concurrency
|
||||
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
|
||||
if sharedFFmpegService != nil {
|
||||
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
|
||||
if isItemAudio || isItemVideo {
|
||||
wg.Go(func() {
|
||||
// Extract metadata for audio files (without album art for performance)
|
||||
if isItemAudio {
|
||||
err := extractAudioMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, opts.AlbumArt, opts.Metadata, sharedFFmpegService)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
} else {
|
||||
mu.Lock()
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
} else {
|
||||
// Extract duration for video files
|
||||
err := extractVideoMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, sharedFFmpegService)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
|
||||
} else {
|
||||
mu.Lock()
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping).
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
|
||||
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
|
||||
// Add OnlyOffice ID if applicable
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
@@ -105,249 +116,85 @@ func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileIn
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeFileInfoListing shapes files/folders for FileInfoFaster JSON:
|
||||
// non-directories omit children (nil + omitempty); directories use empty slices instead of null.
|
||||
func normalizeFileInfoListing(info *iteminfo.ExtendedFileInfo) {
|
||||
if info == nil {
|
||||
return
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
if access == nil {
|
||||
return response, fmt.Errorf("access not provided")
|
||||
}
|
||||
if info.Type == "directory" {
|
||||
info.Files = utils.NonNilSlice(info.Files)
|
||||
info.Folders = utils.NonNilSlice(info.Folders)
|
||||
return
|
||||
}
|
||||
info.Files = nil
|
||||
info.Folders = nil
|
||||
}
|
||||
|
||||
// CheckPermissions validates user access and returns the resolved index path and user scope
|
||||
func CheckPermissions(opts utils.FileOptions, user *users.User) (string, string, error) {
|
||||
return CheckPermissionsFunc(opts, user)
|
||||
}
|
||||
|
||||
// checkPermissionsImpl is the actual implementation of CheckPermissions
|
||||
func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service) (string, string, error) {
|
||||
if user == nil {
|
||||
return "", "", fmt.Errorf("user not provided")
|
||||
return response, fmt.Errorf("user not provided")
|
||||
}
|
||||
if opts.Path == "" {
|
||||
return "", "", fmt.Errorf("%w: path not provided", errors.ErrInvalidRequestParams)
|
||||
return response, fmt.Errorf("path not provided")
|
||||
}
|
||||
if opts.Source == "" {
|
||||
return "", "", fmt.Errorf("%w: source not provided", errors.ErrInvalidRequestParams)
|
||||
}
|
||||
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return "", "", fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
|
||||
// Resolve user scope
|
||||
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
|
||||
if scopeErr != nil || userScope == "" {
|
||||
return "", "", fmt.Errorf("user has no access to source: %v", opts.Source)
|
||||
}
|
||||
|
||||
safePath, err := utils.SanitizePath(opts.Path)
|
||||
if err != nil {
|
||||
return "", "", errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
indexPath := utils.JoinScopedIndexPath(userScope, safePath)
|
||||
parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true)
|
||||
if err != nil {
|
||||
return "", "", errors.ErrAccessDenied
|
||||
}
|
||||
if !s.accessPermitted(idx.Path, parsedPath, user.Username) {
|
||||
return indexPath, "", errors.ErrAccessDenied
|
||||
}
|
||||
return indexPath, userScope, nil
|
||||
}
|
||||
|
||||
type Items struct {
|
||||
Files []string `json:"files,omitempty"`
|
||||
Folders []string `json:"folders,omitempty"`
|
||||
}
|
||||
|
||||
// This removes files whose names match any extension in hideFileExt.
|
||||
func filterFilesByExt(files []iteminfo.ExtendedItemInfo, hideFileExt string) []iteminfo.ExtendedItemInfo {
|
||||
if hideFileExt == "" {
|
||||
return files
|
||||
}
|
||||
filtered := files[:0]
|
||||
for _, f := range files {
|
||||
if !utils.HideFileByExt(f.Name, hideFileExt) {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func GetDirItems(opts utils.FileOptions, user *users.User) (Items, error) {
|
||||
return getDirItemsImpl(opts, user, svc())
|
||||
}
|
||||
|
||||
func getDirItemsImpl(opts utils.FileOptions, user *users.User, s *Service) (Items, error) {
|
||||
items := Items{}
|
||||
indexPath, userScope, topLevelErr := checkPermissionsImpl(opts, user, s)
|
||||
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
|
||||
if topLevelErr != nil && !accessRulesErr {
|
||||
return items, topLevelErr
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return items, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
infoReq := indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
HideFileExt: opts.HideFileExt,
|
||||
Expand: true,
|
||||
SkipExtendedAttrs: true,
|
||||
}
|
||||
if opts.FollowSymlinks {
|
||||
infoReq.BoundIndexPath = userScope
|
||||
}
|
||||
info, err := idx.GetFileInfo(infoReq)
|
||||
if err != nil {
|
||||
return items, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
if info.Type != "directory" {
|
||||
return items, fmt.Errorf("path is not a directory: %v ", indexPath)
|
||||
}
|
||||
if err := s.checkChildItemAccess(info, idx, user.Username); err != nil {
|
||||
return items, err
|
||||
}
|
||||
hasAccessibleItems := len(info.Files) > 0 || len(info.Folders) > 0
|
||||
if accessRulesErr && !hasAccessibleItems {
|
||||
return items, errors.ErrAccessDenied
|
||||
}
|
||||
if opts.Only == "files" || opts.Only == "" {
|
||||
for _, file := range info.Files {
|
||||
if opts.HideFileExt != "" && utils.HideFileByExt(file.Name, opts.HideFileExt) {
|
||||
continue
|
||||
}
|
||||
items.Files = append(items.Files, file.Name)
|
||||
}
|
||||
}
|
||||
if opts.Only == "folders" || opts.Only == "" {
|
||||
for _, folder := range info.Folders {
|
||||
items.Folders = append(items.Folders, folder.Name)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// FileInfoFasterFunc is a variable that can be mocked in tests
|
||||
var FileInfoFasterFunc = func(opts utils.FileOptions, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return fileInfoFasterImpl(opts, user, svc())
|
||||
}
|
||||
|
||||
func FileInfoFaster(opts utils.FileOptions, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return FileInfoFasterFunc(opts, user)
|
||||
}
|
||||
|
||||
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
|
||||
func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*iteminfo.ExtendedFileInfo, error) {
|
||||
response := &iteminfo.ExtendedFileInfo{}
|
||||
indexPath, userScope, topLevelErr := checkPermissionsImpl(opts, user, s)
|
||||
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
|
||||
if topLevelErr != nil && !accessRulesErr {
|
||||
return response, topLevelErr
|
||||
return response, fmt.Errorf("source not provided")
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return response, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
|
||||
// Resolve user scope
|
||||
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
|
||||
if scopeErr != nil || userScope == "" {
|
||||
return response, fmt.Errorf("user has no access to source: %v", opts.Source)
|
||||
}
|
||||
|
||||
safePath, err := utils.SanitizeUserPath(opts.Path)
|
||||
if err != nil {
|
||||
return response, errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Combine scope + sanitized path
|
||||
indexPath := utils.JoinPathAsUnix(userScope, safePath)
|
||||
// Layer 1: USER ACCESS CONTROL
|
||||
// Quick check: Does THIS user have permission?
|
||||
if !access.Permitted(idx.Path, indexPath, user.Username) {
|
||||
return response, errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Layer 2: INDEX RULES (global)
|
||||
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
|
||||
fileInfoReq := indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
HideFileExt: opts.HideFileExt,
|
||||
Expand: opts.Expand,
|
||||
SkipExtendedAttrs: opts.SkipExtendedAttrs,
|
||||
}
|
||||
if opts.FollowSymlinks {
|
||||
fileInfoReq.BoundIndexPath = userScope
|
||||
}
|
||||
info, err := idx.GetFileInfo(fileInfoReq)
|
||||
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
|
||||
IndexPath: indexPath,
|
||||
FollowSymlinks: opts.FollowSymlinks,
|
||||
ShowHidden: opts.ShowHidden,
|
||||
Expand: opts.Expand,
|
||||
IsRoutineScan: false, // API call
|
||||
})
|
||||
if err != nil {
|
||||
return response, err // Path excluded by index rules OR doesn't exist
|
||||
}
|
||||
// otherwise response keeps unfiltered Folders/Files while CheckChildItemAccess only mutates info.
|
||||
if info.Type == "directory" {
|
||||
if err := s.checkChildItemAccess(info, idx, user.Username); err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
hasAccessibleItems := len(info.Files) > 0 || len(info.Folders) > 0
|
||||
if accessRulesErr && !hasAccessibleItems {
|
||||
return response, errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
// Build response
|
||||
response.FileInfo = *info
|
||||
if opts.FollowSymlinks && info.Type != "directory" {
|
||||
realPath, _, err := idx.GetRealPathScoped(userScope, indexPath)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.RealPath = realPath
|
||||
} else {
|
||||
response.RealPath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
|
||||
}
|
||||
response.RealPath = filepath.Join(idx.Path, indexPath)
|
||||
response.Source = opts.Source
|
||||
if user.Permissions.Share && opts.ShowSharedAttr {
|
||||
for i := range response.Files {
|
||||
file := &response.Files[i]
|
||||
file.IsShared = s.pathIsShared(response.Path+file.Name, idx.Path, user.ID)
|
||||
}
|
||||
for i := range response.Folders {
|
||||
folder := &response.Folders[i]
|
||||
folder.IsShared = s.pathIsShared(response.Path+folder.Name, idx.Path, user.ID)
|
||||
}
|
||||
response.IsShared = s.pathIsShared(response.Path, idx.Path, user.ID)
|
||||
}
|
||||
if info.Type == "directory" && opts.ShowPinnedItems {
|
||||
if opts.ShareHash != "" {
|
||||
if link, ok := s.shareByHash(opts.ShareHash); ok {
|
||||
if shareRelDir, err := link.ShareRelativeDir(response.Path); err == nil {
|
||||
response.PinnedItems = link.PinnedItems.NamesForDirectory(shareRelDir)
|
||||
}
|
||||
}
|
||||
} else if source, ok := users.ResolveSourceKey(response.Source); ok {
|
||||
response.PinnedItems = user.PinnedNamesForDirectory(source.Path, response.Path)
|
||||
|
||||
// Layer 3: FILTER CHILDREN (user access)
|
||||
// Remove child items THIS user can't access
|
||||
if info.Type == "directory" {
|
||||
if err := access.CheckChildItemAccess(response, idx, user.Username); err != nil {
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
normalizeFileInfoListing(response)
|
||||
}()
|
||||
if opts.SkipExtendedAttrs {
|
||||
if info.Type == "directory" && opts.HideFileExt != "" {
|
||||
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
// Process directory metadata if requested
|
||||
if info.Type == "directory" {
|
||||
processDirectoryMetadata(response, idx, opts)
|
||||
if opts.HideFileExt != "" {
|
||||
response.Files = filterFilesByExt(response.Files, opts.HideFileExt)
|
||||
}
|
||||
}
|
||||
|
||||
// Process single file content/metadata
|
||||
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
|
||||
if opts.Content || opts.Metadata || isAudioVideo {
|
||||
processContent(response, idx, opts)
|
||||
}
|
||||
|
||||
// Finalize response (OnlyOffice ID, scope stripping)
|
||||
finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -358,13 +205,9 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
if isFolder {
|
||||
return
|
||||
}
|
||||
if isVideo && !opts.Metadata {
|
||||
return
|
||||
}
|
||||
if isAudio && !(opts.Metadata || opts.AlbumArt) {
|
||||
return
|
||||
}
|
||||
|
||||
if isVideo {
|
||||
// Extract duration for video
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
@@ -375,6 +218,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
info.Metadata = extItem.Metadata
|
||||
}
|
||||
|
||||
// Handle subtitles if requested
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
|
||||
if exists {
|
||||
@@ -384,29 +228,27 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
info.Subtitles = append(info.Subtitles, subtitles...)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Audio: run tag/album-art extraction when full metadata or preview (AlbumArt) is requested.
|
||||
// Preview uses AlbumArt without Metadata so we do not load arbitrary files as text content.
|
||||
if isAudio {
|
||||
// Create an ExtendedItemInfo to hold the metadata
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, false, nil)
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
// Copy metadata to ExtendedFileInfo
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasMetadata = true
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !opts.Content {
|
||||
return
|
||||
}
|
||||
|
||||
// Process text content for non-video, non-audio files
|
||||
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
|
||||
content, err := getContent(info.RealPath)
|
||||
@@ -431,57 +273,10 @@ func generateOfficeId(realPath string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
// Parses raw LRC text into a slice of Lyric.
|
||||
// Lines with [hh:mm:ss.xx] (timestamps) will be parsed to ms for synced lyrics
|
||||
// The ones without timestamp are kept as unsynced (timestamp 0).
|
||||
func parseLRC(raw string) []iteminfo.Lyric {
|
||||
var lines []iteminfo.Lyric
|
||||
scanner := bufio.NewScanner(strings.NewReader(raw))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
matches := reDuration.FindStringSubmatch(line)
|
||||
if len(matches) == 6 {
|
||||
// Hours default to 0 (if not present in the timestamps)
|
||||
h := 0
|
||||
if matches[1] != "" {
|
||||
h, _ = strconv.Atoi(matches[1])
|
||||
}
|
||||
min, _ := strconv.Atoi(matches[2])
|
||||
sec, _ := strconv.Atoi(matches[3])
|
||||
|
||||
msStr := matches[4]
|
||||
// Normalise to 3 digits, some songs have more than 3 digits in the milliseconds
|
||||
switch {
|
||||
case len(msStr) < 3:
|
||||
msStr += strings.Repeat("0", 3-len(msStr))
|
||||
case len(msStr) > 3:
|
||||
msStr = msStr[:3]
|
||||
}
|
||||
ms, _ := strconv.Atoi(msStr)
|
||||
timestamp := int64((h*3600+min*60+sec)*1000 + ms)
|
||||
text := strings.TrimSpace(matches[5])
|
||||
lines = append(lines, iteminfo.Lyric{
|
||||
Text: text,
|
||||
Timestamp: timestamp,
|
||||
})
|
||||
} else {
|
||||
// Line without a valid timestamp – treat as unsynced (timestamp 0)
|
||||
lines = append(lines, iteminfo.Lyric{
|
||||
Text: line,
|
||||
Timestamp: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
|
||||
// and optionally extracts duration using the ffmpeg service with concurrency control
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -517,36 +312,13 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
track, _ := m.Track()
|
||||
item.Metadata.Track = track
|
||||
|
||||
// Check if lyrics are available without extract them
|
||||
if rawLyrics := m.Lyrics(); rawLyrics != "" {
|
||||
item.Metadata.HasLyrics = true
|
||||
} else {
|
||||
// Check for sidecar .lrc file
|
||||
dir := filepath.Dir(realPath)
|
||||
base := filepath.Base(realPath)
|
||||
ext := filepath.Ext(base)
|
||||
nameWithoutExt := strings.TrimSuffix(base, ext)
|
||||
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
|
||||
if _, err := os.Stat(lrcPath); err == nil {
|
||||
item.Metadata.HasLyrics = true
|
||||
}
|
||||
}
|
||||
|
||||
if getLyrics {
|
||||
lyrics, err := ExtractLyrics(realPath)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract lyrics for %s: %v", realPath, err)
|
||||
} else if len(lyrics) > 0 {
|
||||
item.Metadata.Lyrics = lyrics
|
||||
}
|
||||
}
|
||||
|
||||
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
|
||||
// This respects concurrency limits and gracefully handles missing ffmpeg
|
||||
if getDuration {
|
||||
// Use provided service or create a new one for backward compatibility
|
||||
service := ffmpegService
|
||||
if service == nil {
|
||||
service = ffmpeg.Get()
|
||||
service = ffmpeg.NewFFmpegService(5, false, "")
|
||||
}
|
||||
if service != nil {
|
||||
if duration, err := service.GetMediaDuration(ctx, realPath); err == nil {
|
||||
@@ -572,76 +344,26 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns lyrics from an audio file (from embedded tags or from a .lrc file with the same name).
|
||||
func ExtractLyrics(realPath string) ([]iteminfo.Lyric, error) {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
m, err := tag.ReadFrom(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lyrics []iteminfo.Lyric
|
||||
if raw := m.Lyrics(); raw != "" {
|
||||
lyrics = parseLRC(raw)
|
||||
}
|
||||
if len(lyrics) == 0 {
|
||||
// Check for sidecar .lrc file
|
||||
dir := filepath.Dir(realPath)
|
||||
base := filepath.Base(realPath)
|
||||
ext := filepath.Ext(base)
|
||||
nameWithoutExt := strings.TrimSuffix(base, ext)
|
||||
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
|
||||
if data, err := os.ReadFile(lrcPath); err == nil {
|
||||
lyrics = parseLRC(string(data))
|
||||
}
|
||||
}
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
|
||||
// extractVideoMetadata extracts duration from video files using ffprobe
|
||||
// If ffmpegService is nil, a new service will be created (for backward compatibility)
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
|
||||
// Use provided service or create a new one for backward compatibility
|
||||
service := ffmpegService
|
||||
if service == nil {
|
||||
service = ffmpeg.Get()
|
||||
service = ffmpeg.NewFFmpegService(10, false, "")
|
||||
}
|
||||
if service == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := service.ProbeFile(ctx, realPath)
|
||||
if err != nil {
|
||||
duration, durErr := service.GetMediaDuration(ctx, realPath)
|
||||
if durErr != nil {
|
||||
if service != nil {
|
||||
duration, err := service.GetMediaDuration(ctx, realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duration > 0 {
|
||||
item.Metadata = &iteminfo.MediaMetadata{Duration: int(duration)}
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Duration: int(duration),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
meta := &iteminfo.MediaMetadata{}
|
||||
if info.Duration > 0 {
|
||||
meta.Duration = int(info.Duration)
|
||||
}
|
||||
if info.VideoCodec != "" {
|
||||
meta.VideoCodec = info.VideoCodec
|
||||
}
|
||||
if info.AudioCodec != "" {
|
||||
meta.AudioCodec = info.AudioCodec
|
||||
}
|
||||
if info.FormatName != "" {
|
||||
meta.Container = info.FormatName
|
||||
}
|
||||
if meta.Duration > 0 || meta.VideoCodec != "" || meta.AudioCodec != "" || meta.Container != "" {
|
||||
item.Metadata = meta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -650,14 +372,9 @@ func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
if index == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
// Safety guard: never allow deletion of the source root directory itself
|
||||
cleanAbs := filepath.Clean(absPath)
|
||||
if cleanAbs == filepath.Clean(index.Path) {
|
||||
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
|
||||
}
|
||||
|
||||
if !index.Config.ResolvedRules.IndexingDisabled {
|
||||
indexPath := index.MakeIndexPath(absPath, isDir).String()
|
||||
if !index.Config.DisableIndexing {
|
||||
indexPath := index.MakeIndexPath(absPath, isDir)
|
||||
|
||||
// Perform the physical deletion
|
||||
err := os.RemoveAll(absPath)
|
||||
@@ -677,7 +394,7 @@ func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
|
||||
// Refresh the parent directory to recalculate sizes and update counts
|
||||
refreshConfig := utils.FileOptions{
|
||||
Path: index.MakeIndexPath(filepath.Dir(absPath), true).String(),
|
||||
Path: index.MakeIndexPath(filepath.Dir(absPath), true),
|
||||
IsDir: true,
|
||||
}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
@@ -693,11 +410,11 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
if idx == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
if idx.Config.ResolvedRules.IndexingDisabled {
|
||||
if idx.Config.DisableIndexing {
|
||||
return nil
|
||||
}
|
||||
// Always normalize path using MakeIndexPath
|
||||
path = idx.MakeIndexPath(path, isDir).String()
|
||||
path = idx.MakeIndexPath(path, isDir)
|
||||
|
||||
// Check if path is a symlink
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
@@ -768,11 +485,7 @@ func validateMoveDestination(src, dst string, isSrcDir bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string) error {
|
||||
return moveResourceImpl(isSrcDir, sourceIndex, destIndex, realsrc, realdst, svc())
|
||||
}
|
||||
|
||||
func moveResourceImpl(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string, s *Service) error {
|
||||
func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage, a *access.Storage) error {
|
||||
// Check if source and destination are the same file
|
||||
if realsrc == realdst {
|
||||
return fmt.Errorf("cannot move a file to itself: %s", realsrc)
|
||||
@@ -794,7 +507,7 @@ func moveResourceImpl(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst st
|
||||
}
|
||||
|
||||
// Prepare paths for index operations
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc, isSrcDir).String()
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc, isSrcDir)
|
||||
srcParentPath := filepath.Dir(realsrc)
|
||||
|
||||
// Perform the physical move
|
||||
@@ -805,7 +518,7 @@ func moveResourceImpl(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst st
|
||||
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !srcIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
if !srcIdx.Config.DisableIndexing {
|
||||
go func() {
|
||||
if isSrcDir {
|
||||
srcIdx.DeleteMetadata(srcIndexPath, true, true)
|
||||
@@ -820,7 +533,7 @@ func moveResourceImpl(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst st
|
||||
|
||||
// Handle DESTINATION indexing
|
||||
// Run async to avoid blocking the HTTP response
|
||||
if !dstIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
if !dstIdx.Config.DisableIndexing {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// Recursively index the moved directory tree
|
||||
@@ -842,15 +555,16 @@ func moveResourceImpl(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst st
|
||||
}
|
||||
}
|
||||
|
||||
// Share path reconciliation is handled by state.UpdateSharesForMovedResource (see http/resource.go).
|
||||
// Use backend source paths to match how shares are stored
|
||||
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc, isSrcDir), dstIdx.Path, dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
|
||||
|
||||
// Update access rules for the moved path
|
||||
if srcIdx.Path == dstIdx.Path {
|
||||
go s.updateAccessRulesOnMove(
|
||||
srcIdx.Path,
|
||||
srcIdx.MakeIndexPath(realsrc, isSrcDir),
|
||||
dstIdx.MakeIndexPath(realdst, isSrcDir),
|
||||
)
|
||||
if a != nil {
|
||||
// If moving within the same source, update the rules
|
||||
if srcIdx.Path == dstIdx.Path {
|
||||
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc, isSrcDir), dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
|
||||
}
|
||||
// Cross-source moves don't preserve access rules (they're source-specific)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -877,7 +591,7 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
|
||||
// Refresh source (non-recursive, just metadata)
|
||||
srcIdx := indexing.GetIndex(sourceIndex)
|
||||
if srcIdx != nil && !srcIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
if srcIdx != nil && !srcIdx.Config.DisableIndexing {
|
||||
srcRefreshPath := realsrc
|
||||
if !isSrcDir {
|
||||
srcRefreshPath = filepath.Dir(realsrc)
|
||||
@@ -888,7 +602,7 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
// Refresh destination (RECURSIVE for directories to capture full tree)
|
||||
// Run async to avoid blocking the HTTP response
|
||||
dstIdx := indexing.GetIndex(destIndex)
|
||||
if dstIdx != nil && !dstIdx.Config.ResolvedRules.IndexingDisabled {
|
||||
if dstIdx != nil && !dstIdx.Config.DisableIndexing {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// Recursively index the copied directory tree
|
||||
@@ -975,21 +689,22 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyDefaultFilePerm := false
|
||||
var stat os.FileInfo
|
||||
stat, err = os.Stat(realPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("stat file: %w", err)
|
||||
// Check if the destination exists and is a directory
|
||||
if stat, err = os.Stat(realPath); err == nil {
|
||||
if stat.IsDir() {
|
||||
// If it's a directory and we're trying to create a file, remove the directory first
|
||||
err = os.RemoveAll(realPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove existing directory to create file: %v", err)
|
||||
}
|
||||
}
|
||||
applyDefaultFilePerm = true
|
||||
} else if stat.IsDir() {
|
||||
return fmt.Errorf("%w: %q", errors.ErrIsDirectory, path)
|
||||
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
|
||||
}
|
||||
|
||||
// Open the file for writing (create if it doesn't exist, truncate if it does)
|
||||
// For new files: permissions are set to fileutils.PermFile (subject to umask, then Chmod bypasses umask)
|
||||
// For existing regular files: do not Chmod so saves preserve mode and special bits
|
||||
// For new files: permissions are set to fileutils.PermFile (subject to umask, which is usually acceptable)
|
||||
// For existing files: permissions are preserved automatically (O_TRUNC doesn't change them)
|
||||
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1002,11 +717,11 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if applyDefaultFilePerm {
|
||||
err = os.Chmod(realPath, fileutils.PermFile)
|
||||
if err != nil {
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
}
|
||||
// Explicitly set file permissions to bypass umask
|
||||
err = os.Chmod(realPath, fileutils.PermFile)
|
||||
if err != nil {
|
||||
// Handle chmod error gracefully
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
|
||||
}
|
||||
|
||||
// Refresh the file itself
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
+36
-36
@@ -1,36 +1,36 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
assetFs fs.FS
|
||||
)
|
||||
|
||||
// Custom dirFS to handle both embedded and non-embedded file systems
|
||||
type dirFS struct {
|
||||
http.Dir
|
||||
}
|
||||
|
||||
// Implement the Open method for dirFS, which wraps http.Dir
|
||||
func (d dirFS) Open(name string) (fs.File, error) {
|
||||
return d.Dir.Open(name)
|
||||
}
|
||||
|
||||
// InitAssetFS initializes the asset filesystem for the application
|
||||
// This should be called once during startup before http or preview services start
|
||||
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
|
||||
if useEmbedded {
|
||||
assetFs = embeddedAssets
|
||||
} else {
|
||||
// Dev mode: Serve files from internal/web/dist directory
|
||||
assetFs = dirFS{Dir: http.Dir("internal/web/dist")}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAssetFS returns the initialized asset filesystem
|
||||
func GetAssetFS() fs.FS {
|
||||
return assetFs
|
||||
}
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
assetFs fs.FS
|
||||
)
|
||||
|
||||
// Custom dirFS to handle both embedded and non-embedded file systems
|
||||
type dirFS struct {
|
||||
http.Dir
|
||||
}
|
||||
|
||||
// Implement the Open method for dirFS, which wraps http.Dir
|
||||
func (d dirFS) Open(name string) (fs.File, error) {
|
||||
return d.Dir.Open(name)
|
||||
}
|
||||
|
||||
// InitAssetFS initializes the asset filesystem for the application
|
||||
// This should be called once during startup before http or preview services start
|
||||
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
|
||||
if useEmbedded {
|
||||
assetFs = embeddedAssets
|
||||
} else {
|
||||
// Dev mode: Serve files from http/dist directory
|
||||
assetFs = dirFS{Dir: http.Dir("http/dist")}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAssetFS returns the initialized asset filesystem
|
||||
func GetAssetFS() fs.FS {
|
||||
return assetFs
|
||||
}
|
||||
File renamed without changes.
@@ -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 {
|
||||
+9
-116
@@ -1,12 +1,10 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -14,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.
|
||||
@@ -115,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
|
||||
}
|
||||
@@ -128,7 +90,8 @@ func copySingleFile(source, dest string) error {
|
||||
defer dst.Close()
|
||||
|
||||
// Copy the contents of the file.
|
||||
if err = copyFileContents(dst, src); err != nil {
|
||||
_, err = io.Copy(dst, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -140,33 +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
|
||||
}
|
||||
|
||||
func copyFileContents(dst io.Writer, src io.Reader) error {
|
||||
_, err := io.Copy(dst, src)
|
||||
if !errors.Is(err, syscall.EBADF) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some FUSE filesystems reject copy_file_range. Hide the optional fast-path
|
||||
// methods so io.Copy retries from the current offsets with buffered I/O.
|
||||
_, err = io.Copy(struct{ io.Writer }{dst}, struct{ io.Reader }{src})
|
||||
return err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -196,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.
|
||||
@@ -292,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+1
-14
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !linux
|
||||
//go:build !windows
|
||||
|
||||
package fileutils
|
||||
|
||||
@@ -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
|
||||
}
|
||||
-15
@@ -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
|
||||
}
|
||||
@@ -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{}{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
@@ -145,15 +145,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 +173,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,
|
||||
@@ -188,10 +190,14 @@ func VerifyTotpCode(user *users.User, code string) error {
|
||||
return fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
if totpSecret != "" || !user.OtpEnabled {
|
||||
user.TOTPSecret = totpSecret
|
||||
user.TOTPNonce = totpNonce
|
||||
user.OtpEnabled = true
|
||||
TotpCache.Delete(user.Username)
|
||||
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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+269
-100
@@ -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,157 @@ 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]
|
||||
|
||||
// Handle old syntax: "set -u user,pass -c config.yaml"
|
||||
if subcommand == "-u" {
|
||||
// Parse flags starting from os.Args[2:] to include the -u flag
|
||||
err := setCmd.Parse(os.Args[2:])
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
|
||||
err = setUser(dbConfig, asAdmin)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// New pattern: subcommand-based (e.g., "set user" or "set rule")
|
||||
// Parse flags starting from os.Args[3:] to skip the subcommand
|
||||
err := setCmd.Parse(os.Args[3:])
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
switch subcommand {
|
||||
case "rule":
|
||||
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
default:
|
||||
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", subcommand)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
case "version":
|
||||
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
|
||||
Version : %v
|
||||
Commit : %v
|
||||
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
|
||||
`, version.Version, version.CommitSHA, version.Version)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
|
||||
// Validate required parameters
|
||||
if fsPath == "" {
|
||||
return fmt.Errorf("real filesystem path is required: use -f <fsPath>")
|
||||
}
|
||||
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 +185,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 {
|
||||
logger.Infof("Converting user %s from %s to password login method", username, user.LoginMethod)
|
||||
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,30 +258,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.LoginMethod != users.LoginMethodPassword {
|
||||
return fmt.Errorf("user %s cannot be promoted: only password-login users can be granted admin via CLI", username)
|
||||
}
|
||||
if user.Permissions.Admin {
|
||||
fmt.Printf("user %s is already an admin\n", username)
|
||||
return nil
|
||||
}
|
||||
user.Permissions.Admin = true
|
||||
if user.Version == 0 {
|
||||
user.Version = users.CurrentUserMigrationVersion
|
||||
}
|
||||
err = state.UpdateUser(&user, "", "permissions")
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not promote user: %v", err)
|
||||
}
|
||||
fmt.Printf("successfully promoted user to admin: %s\n", username)
|
||||
return nil
|
||||
// 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')
|
||||
@@ -168,6 +315,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)
|
||||
@@ -182,45 +332,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)
|
||||
}
|
||||
@@ -241,54 +410,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() {
|
||||
@@ -301,6 +469,7 @@ func generateYaml() {
|
||||
if generateConfig {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func SplitByMultiple(str string) []string {
|
||||
|
||||
@@ -1,193 +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 TestParseInitCommand(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"init"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "init", 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")
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupCLIUserTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.InitializeCLI("../../../_docker/src/noauth/backend/config.yaml")
|
||||
settings.Env.IsPlaywright = true
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
store, _, err := state.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(store)
|
||||
t.Cleanup(func() { _ = state.Close() })
|
||||
}
|
||||
|
||||
func TestSetUserConvertsOIDCToPassword(t *testing.T) {
|
||||
setupCLIUserTest(t)
|
||||
|
||||
oidcUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(oidcUser, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := setUser("graham", "newpassword", false); err != nil {
|
||||
t.Fatalf("setUser: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := state.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.LoginMethod != users.LoginMethodPassword {
|
||||
t.Fatalf("loginMethod = %q, want password", loaded.LoginMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteUserRejectsNonPasswordLoginMethod(t *testing.T) {
|
||||
setupCLIUserTest(t)
|
||||
|
||||
oidcUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(oidcUser, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := promoteUser("graham")
|
||||
if err == nil {
|
||||
t.Fatal("promoteUser: expected error for oidc user")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "password-login") {
|
||||
t.Fatalf("promoteUser err = %q, want password-login restriction", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteUserAllowsPasswordLoginMethod(t *testing.T) {
|
||||
setupCLIUserTest(t)
|
||||
|
||||
passwordUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(passwordUser, "secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := promoteUser("graham"); err != nil {
|
||||
t.Fatalf("promoteUser: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := state.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !loaded.Permissions.Admin {
|
||||
t.Fatal("expected admin after promote")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,234 +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 initCmd 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"`
|
||||
Init initCmd `cmd:"" name:"init" hidden:"" help:"Initialize the database or migrate from a legacy database"`
|
||||
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 (initCmd) Run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SetCmd) Run(ctx *kong.Context) error {
|
||||
if strings.HasPrefix(ctx.Command(), "set rule") {
|
||||
return nil
|
||||
}
|
||||
if s.User == "" {
|
||||
return fmt.Errorf("missing subcommand for 'set'. Use 'set rule' or 'user set'")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "warning: 'set -u' is deprecated; use 'user set' instead")
|
||||
userInfo := strings.SplitN(s.User, ",", 2)
|
||||
if len(userInfo) < 2 || userInfo[0] == "" || userInfo[1] == "" {
|
||||
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", strings.Split(s.User, ","))
|
||||
}
|
||||
return setUser(userInfo[0], userInfo[1], s.Admin)
|
||||
}
|
||||
|
||||
func (r *SetRuleCmd) Run() error {
|
||||
sourceInfo, ok := settings.Config.Server.NameToSource[r.Source]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid source name: %s", r.Source)
|
||||
}
|
||||
return setRule(sourceInfo.Path, r.Path, r.Role, r.Value, r.Allow)
|
||||
}
|
||||
|
||||
func (u *UserSetCmd) Run(globals *Globals) error {
|
||||
password, err := u.Password.resolve(globals.NoInput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return setUser(u.Username, password, u.Admin)
|
||||
}
|
||||
|
||||
func (p *UserPromoteCmd) Run() error {
|
||||
return promoteUser(p.Username)
|
||||
}
|
||||
|
||||
func resolveConfigPath(config *string) {
|
||||
envConfig := os.Getenv("FILEBROWSER_CONFIG")
|
||||
if *config == "" {
|
||||
if envConfig != "" {
|
||||
*config = envConfig
|
||||
} else {
|
||||
*config = "config.yaml"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireExistingConfig(config string) {
|
||||
envConfig := os.Getenv("FILEBROWSER_CONFIG")
|
||||
if envConfig != "" && config == envConfig {
|
||||
if _, err := os.Stat(config); err != nil {
|
||||
logger.Fatalf("config file %v does not exist, please create it or set the FILEBROWSER_CONFIG environment variable to a valid config file path", config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runCLI() (keepGoing bool, dbExists bool) {
|
||||
generateYaml()
|
||||
|
||||
parser, err := kong.New(&rootCLI,
|
||||
kong.Name("filebrowser"),
|
||||
kong.Description("FileBrowser Quantum - A modern web-based file manager"),
|
||||
kong.Bind(&rootCLI.Globals),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to configure CLI: %v", err)
|
||||
}
|
||||
|
||||
ctx, err := parser.Parse(os.Args[1:])
|
||||
parser.FatalIfErrorf(err)
|
||||
|
||||
resolveConfigPath(&rootCLI.Config)
|
||||
configPath = rootCLI.Config
|
||||
|
||||
cmd := ctx.Command()
|
||||
switch {
|
||||
case cmd == "" || cmd == "run":
|
||||
requireExistingConfig(configPath)
|
||||
dbExists = initializeDatabase(configPath, false)
|
||||
return true, dbExists
|
||||
case cmd == "version" || cmd == "setup":
|
||||
parser.FatalIfErrorf(ctx.Run(&rootCLI))
|
||||
return false, false
|
||||
case cmd == "init" || cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
|
||||
requireExistingConfig(configPath)
|
||||
dbExists = initializeDatabase(configPath, true)
|
||||
parser.FatalIfErrorf(ctx.Run(&rootCLI))
|
||||
return false, dbExists
|
||||
default:
|
||||
parser.Fatalf("unexpected command: %q", cmd)
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
+60
-76
@@ -11,63 +11,53 @@ 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/icons"
|
||||
"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, cliMode bool) bool {
|
||||
if cliMode {
|
||||
settings.InitializeCLI(configFile)
|
||||
} else {
|
||||
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)
|
||||
func getStore(configFile string) bool {
|
||||
// Use the config file (global flag)
|
||||
settings.Initialize(configFile)
|
||||
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
|
||||
if err != nil {
|
||||
logger.Fatalf("could not 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 != "" {
|
||||
@@ -86,17 +76,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{}
|
||||
@@ -120,7 +110,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 {
|
||||
@@ -133,24 +123,26 @@ func StartFilebrowser() {
|
||||
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)
|
||||
if isNewDb {
|
||||
if err := store.Indexing.ResetAllComplexities(); err != nil {
|
||||
logger.Errorf("Failed to reset index complexities: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
go indexing.Initialize(source, false, isNewDb)
|
||||
}
|
||||
analytics.StartReporter()
|
||||
validateUserInfo(!dbExists)
|
||||
validateOfficeIntegration()
|
||||
validateAccessRules()
|
||||
validateShareInfo()
|
||||
// Start the rootCMD in a goroutine
|
||||
go func() {
|
||||
if err := rootCMD(ctx, &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
|
||||
@@ -180,22 +172,20 @@ 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()
|
||||
embeddedAssets := fbhttp.GetEmbeddedAssets()
|
||||
subAssets, err := fs.Sub(embeddedAssets, "embed")
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to create sub filesystem: %v", err)
|
||||
@@ -204,26 +194,20 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
|
||||
} else {
|
||||
fileutils.InitAssetFS(nil, false)
|
||||
}
|
||||
|
||||
|
||||
// Start preview service
|
||||
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
|
||||
if err != nil {
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
|
||||
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
|
||||
|
||||
// Generate PWA icons after preview service is initialized
|
||||
if err := icons.GeneratePWAIcons(); err != nil {
|
||||
logger.Warningf("Failed to generate PWA icons: %v", err)
|
||||
}
|
||||
|
||||
// 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
@@ -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
|
||||
}
|
||||
|
||||
+113
-148
@@ -4,13 +4,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/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,137 +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
|
||||
|
||||
scopesChanged := updateUserScopes(user)
|
||||
if scopesChanged {
|
||||
changedFields = append(changedFields, "backendScopes")
|
||||
updateUser := false
|
||||
if updateUserScopes(user) {
|
||||
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, scopesChanged) {
|
||||
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")
|
||||
if updateSidebarLinks(user) {
|
||||
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
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
changedFields = append(changedFields, "permissions", "password", "loginMethod")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -162,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
|
||||
@@ -208,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
|
||||
}
|
||||
@@ -233,51 +190,59 @@ func updatePreviewSettings(user *users.User) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// updateSidebarLinks normalizes saved links and adds defaults for new or changed scopes.
|
||||
func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
|
||||
links, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks)
|
||||
if scopesChanged || user.SidebarLinks == nil {
|
||||
links, changed = usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
|
||||
// updateSidebarLinks checks if user has stale source links and rebuilds them if needed
|
||||
func updateSidebarLinks(user *users.User) bool {
|
||||
// Count source links and check if any are still valid
|
||||
sourceLinksCount := 0
|
||||
validSourceLinksCount := 0
|
||||
|
||||
for _, link := range user.SidebarLinks {
|
||||
if link.Category == "source" {
|
||||
sourceLinksCount++
|
||||
// Check if this source still exists
|
||||
if link.SourceName != "" {
|
||||
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
|
||||
validSourceLinksCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
|
||||
// If user has no source links, don't update anything
|
||||
if sourceLinksCount == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
user.SidebarLinks = links
|
||||
return true
|
||||
}
|
||||
// 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)
|
||||
|
||||
func updateTokens(user *users.User) bool {
|
||||
if user.Version >= 2 {
|
||||
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)
|
||||
// Remove all existing source links
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if link.Category != "source" {
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
}
|
||||
}
|
||||
user.Version = 2
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeApiTokenPermissions(user *users.User) bool {
|
||||
if user == nil || len(user.Tokens) == 0 {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for name, token := range user.Tokens {
|
||||
if token.Name == "" || name != token.Name {
|
||||
continue
|
||||
}
|
||||
sanitized := users.SanitizeTokenPermissions(token.Permissions)
|
||||
if sanitized != token.Permissions {
|
||||
token.Permissions = sanitized
|
||||
user.Tokens[name] = token
|
||||
changed = true
|
||||
// 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
|
||||
}
|
||||
@@ -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, true)
|
||||
|
||||
foundScope := false
|
||||
for _, scope := range graham.BackendScopes {
|
||||
if scope.Path == fixtureAccessSource {
|
||||
foundScope = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundScope {
|
||||
t.Fatalf("graham should gain access scope after merge: %#v", graham.BackendScopes)
|
||||
}
|
||||
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; !ok {
|
||||
t.Fatalf("graham should have access source permissions after merge: %#v", graham.BackendSourcePermissions)
|
||||
}
|
||||
|
||||
foundLink := false
|
||||
for _, link := range usersidebar.FrontendLinks(graham.SidebarLinks, true) {
|
||||
if link.Name == "access" || link.SourceName == fixtureAccessSource {
|
||||
foundLink = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundLink {
|
||||
t.Fatalf("graham should have access sidebar link after merge: %#v", graham.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserScopes_seedsDefaultEnabledWhenEmpty(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: "newuser"},
|
||||
}
|
||||
|
||||
if !updateUserScopes(user) {
|
||||
t.Fatal("expected default-enabled scopes to be seeded")
|
||||
}
|
||||
if len(user.BackendScopes) != 3 {
|
||||
t.Fatalf("scopes=%#v want all default-enabled sources", user.BackendScopes)
|
||||
}
|
||||
}
|
||||
@@ -1,113 +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, false) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: fixturePlaywrightSource, Scope: "/"},
|
||||
{Path: fixtureDockerSource, Scope: "/"},
|
||||
{Path: fixtureAccessSource, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSidebarLinks_keepsDeletedSourceLinkAbsent(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
{Name: "playwright + files", Category: "source", SourceName: fixturePlaywrightSource, Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: fixtureDockerSource, Target: "/"},
|
||||
},
|
||||
},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: fixturePlaywrightSource, Scope: "/"},
|
||||
{Path: fixtureDockerSource, Scope: "/"},
|
||||
{Path: fixtureAccessSource, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user, false) {
|
||||
t.Fatal("expected default source names to be blanked on normalize")
|
||||
}
|
||||
if len(user.SidebarLinks) != 2 {
|
||||
t.Fatalf("got %d sidebar links, want 2: %v", len(user.SidebarLinks), user.SidebarLinks)
|
||||
}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if link.Name != "" {
|
||||
t.Fatalf("expected blank default name, got %#v", link)
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
-67
@@ -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,20 +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")
|
||||
ErrInvalidLoginMethod = errors.New("invalid 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")
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -31,16 +29,6 @@ const (
|
||||
)
|
||||
|
||||
func Initialize(configFile string) {
|
||||
initializeConfig(configFile, false)
|
||||
}
|
||||
|
||||
// InitializeCLI loads config for CLI subcommands without server-only setup (ffmpeg, frontend, etc.).
|
||||
func InitializeCLI(configFile string) {
|
||||
initializeConfig(configFile, true)
|
||||
}
|
||||
|
||||
func initializeConfig(configFile string, cliMode bool) {
|
||||
Env.IsCLIMode = cliMode
|
||||
err := loadConfigWithDefaults(configFile, false)
|
||||
if err != nil {
|
||||
logger.Error("unable to load config, waiting 5 seconds before exiting...")
|
||||
@@ -62,24 +50,23 @@ func initializeConfig(configFile string, cliMode bool) {
|
||||
}
|
||||
setupFs()
|
||||
setupServer()
|
||||
setupHttp()
|
||||
setupAuth(false)
|
||||
setupSources(false)
|
||||
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
|
||||
setupUrls()
|
||||
if cliMode {
|
||||
return
|
||||
}
|
||||
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() {
|
||||
@@ -93,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)
|
||||
@@ -141,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)
|
||||
|
||||
}
|
||||
@@ -331,9 +278,11 @@ func setupFrontend(generate bool) {
|
||||
}
|
||||
// Load custom favicon if configured
|
||||
loadCustomFavicon()
|
||||
// Generate PWA icons from custom favicon if needed
|
||||
generatePWAIcons()
|
||||
}
|
||||
|
||||
func setupMedia(generate bool) {
|
||||
func setupMedia() {
|
||||
// Save user's explicit config before applying defaults
|
||||
userImageConfig := make(map[ImagePreviewType]*bool)
|
||||
for k, v := range Config.Integrations.Media.Convert.ImagePreview {
|
||||
@@ -367,46 +316,6 @@ func setupMedia(generate bool) {
|
||||
for k, v := range userVideoConfig {
|
||||
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) {
|
||||
@@ -417,7 +326,7 @@ func setupSources(generate bool) {
|
||||
if source.Config.Disabled {
|
||||
continue
|
||||
}
|
||||
realPath, err := AbsPath(source.Path)
|
||||
realPath, err := filepath.Abs(source.Path)
|
||||
if err != nil {
|
||||
logger.Fatalf("error getting real path for source %v: %v", source.Path, err)
|
||||
}
|
||||
@@ -429,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 {
|
||||
@@ -438,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 == "" {
|
||||
@@ -453,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
|
||||
@@ -468,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,
|
||||
})
|
||||
}
|
||||
@@ -480,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, "/")
|
||||
@@ -528,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
|
||||
@@ -572,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]
|
||||
|
||||
}
|
||||
|
||||
@@ -584,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)
|
||||
}
|
||||
@@ -613,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 {
|
||||
@@ -626,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)
|
||||
@@ -651,7 +516,6 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
"integrations": true,
|
||||
"frontend": true,
|
||||
"userDefaults": true,
|
||||
"http": true,
|
||||
}
|
||||
|
||||
filteredConfig := make(map[string]interface{})
|
||||
@@ -661,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 {
|
||||
@@ -687,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()
|
||||
|
||||
@@ -830,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",
|
||||
@@ -890,74 +681,38 @@ func SetDefaults(generate bool) Settings {
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initialize ImagePreview map with defaults
|
||||
@@ -975,7 +730,7 @@ func SetDefaults(generate bool) Settings {
|
||||
// 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) {
|
||||
// Get absolute path
|
||||
absolutePath, err = AbsPath(configPath)
|
||||
absolutePath, err = filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not resolve path: %w", err)
|
||||
}
|
||||
@@ -1031,26 +786,23 @@ 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")
|
||||
}
|
||||
// generatePWAIcons sets up PWA icon paths (actual generation happens after preview service starts)
|
||||
func generatePWAIcons() {
|
||||
// Set PWA icons directory in cache
|
||||
Env.PWAIconsDir = 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")
|
||||
}
|
||||
// Initialize with default paths (all icons will be generated)
|
||||
Env.PWAIcon192 = "icons/pwa-icon-192.png"
|
||||
Env.PWAIcon256 = "icons/pwa-icon-256.png"
|
||||
Env.PWAIcon512 = "icons/pwa-icon-512.png"
|
||||
|
||||
// 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
|
||||
// Create icons directory with configured permissions
|
||||
// Note: Parent cache directory should already exist from testCacheDirSpeed()
|
||||
if err := os.MkdirAll(Env.PWAIconsDir, fileutils.PermDir); err != nil {
|
||||
logger.Warningf("Failed to create PWA icons directory %s: %v", Env.PWAIconsDir, err)
|
||||
}
|
||||
return fileutils.ClearDirectoryContents(DownloadCacheDir())
|
||||
}
|
||||
|
||||
func loadLoginIcon() {
|
||||
@@ -1082,9 +834,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{
|
||||
@@ -1102,7 +856,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
|
||||
@@ -1152,6 +925,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
|
||||
}
|
||||
@@ -1184,12 +963,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
|
||||
@@ -1212,10 +995,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:]
|
||||
|
||||
}
|
||||
+1
-1
@@ -23,5 +23,5 @@ func CanConvertVideo(ext string) bool {
|
||||
}
|
||||
|
||||
func MediaEnabled() bool {
|
||||
return Env.FFmpegAvailable
|
||||
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 {
|
||||
Loaded 100 of 899 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user