mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-13 12:41:59 -05:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19d60a8a24 | ||
|
|
d583359f4e | ||
|
|
2c0f9f26d6 | ||
|
|
efe10ec6d5 | ||
|
|
0f37b8120f | ||
|
|
c55107e31b | ||
|
|
ee0307d80a | ||
|
|
e64179d6d2 | ||
|
|
68f0f0204e | ||
|
|
9e18c99b05 | ||
|
|
cdbc7a35f7 | ||
|
|
a18f268547 | ||
|
|
28edf9ce0e | ||
|
|
e99b22349d | ||
|
|
9775cc03fd | ||
|
|
18333e21fd | ||
|
|
ad3d184811 | ||
|
|
d47c2d9c0d | ||
|
|
04e4324981 | ||
|
|
44edfcafa1 | ||
|
|
be2e78f729 | ||
|
|
c22fc59035 | ||
|
|
ad82de28e8 | ||
|
|
0481052308 | ||
|
|
87d241d166 | ||
|
|
092c97f9ae |
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": "20"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
|
||||
"containerEnv": {
|
||||
"CGO_ENABLED": "1",
|
||||
"FILEBROWSER_DEVMODE": "true"
|
||||
},
|
||||
|
||||
"forwardPorts": [80],
|
||||
"portsAttributes": {
|
||||
"80": {
|
||||
"label": "filebrowser app",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
},
|
||||
|
||||
"postCreateCommand": "make setup",
|
||||
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-azuretools.vscode-docker"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-6
@@ -1,15 +1,10 @@
|
||||
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
|
||||
@@ -33,44 +30,3 @@ jobs:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
|
||||
create_beta_release:
|
||||
needs: [test_playwright]
|
||||
if: startsWith(github.event.head_commit.message, 'Beta/v')
|
||||
name: Create Beta Release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
fetch-depth: 0
|
||||
- name: Extract version from commit message
|
||||
id: extract_version
|
||||
shell: bash
|
||||
run: |
|
||||
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
|
||||
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
|
||||
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
- name: Tag release commit on main
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
TAG="${{ steps.extract_version.outputs.tag_name }}"
|
||||
git tag -f "$TAG" "${{ github.sha }}"
|
||||
git push origin "refs/tags/$TAG" --force
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_version.outputs.tag_name }}
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
overwrite_existing: true
|
||||
name: ${{ steps.extract_version.outputs.tag_name }}
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
jobs:
|
||||
prime_docker_cache:
|
||||
name: Prime docker cache
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
|
||||
build_frontend:
|
||||
name: Build frontend
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -40,10 +40,10 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: backend/internal/web/dist
|
||||
path: backend/http/dist
|
||||
build_backend:
|
||||
name: Build backend
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
test_playwright:
|
||||
needs: [build_backend, build_frontend, prime_docker_cache]
|
||||
name: Test Playwright - ${{ matrix.name }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -103,8 +103,8 @@ jobs:
|
||||
run: chmod +x backend/filebrowser
|
||||
- name: Copy frontend build to backend
|
||||
run: |
|
||||
mkdir -p backend/internal/web/embed
|
||||
cp -r frontend/dist/* backend/internal/web/embed
|
||||
mkdir -p backend/http/embed
|
||||
cp -r frontend/dist/* backend/http/embed
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
|
||||
push_pr_to_registry:
|
||||
name: Build docker image
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
jobs:
|
||||
promote-beta-to-stable:
|
||||
name: Promote Beta to Stable
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
|
||||
@@ -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,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -16,16 +16,19 @@ jobs:
|
||||
- working-directory: backend
|
||||
run: go test -race -v ./... -timeout 30s
|
||||
lint-backend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go tool golangci-lint run --path-prefix=backend
|
||||
- uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: 'v2.1.6'
|
||||
working-directory: backend
|
||||
install-mode: goinstall
|
||||
format-backend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
@@ -34,39 +37,23 @@ jobs:
|
||||
- working-directory: backend
|
||||
run: go fmt ./...
|
||||
lint-frontend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run lint
|
||||
test-frontend:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run test
|
||||
check-translations:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run i18n:check
|
||||
check-docs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- run: make generate-docs
|
||||
- run: git diff --exit-code
|
||||
check-icons:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run icons:check
|
||||
run: npm i && npm run i18n:check
|
||||
@@ -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 }}
|
||||
@@ -11,7 +11,7 @@ permissions:
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright - regular
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
create_release_tag:
|
||||
needs: [ test_playwright ]
|
||||
name: Create Release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -11,7 +11,7 @@ permissions:
|
||||
jobs:
|
||||
update_tag:
|
||||
name: Update Release tag
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-9
@@ -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
|
||||
@@ -23,18 +19,15 @@ frontend/tsconfig.tsbuildinfo
|
||||
/frontend/playwright-files
|
||||
/frontend/screenshots
|
||||
/frontend/generated
|
||||
frontend/.venv-icons
|
||||
frontend/.tmp-icons-*
|
||||
/frontend/tests/playwright-files/demo*
|
||||
/frontend/loginAuth.json
|
||||
/frontend/sharePrepStorage.json
|
||||
/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
|
||||
|
||||
+1
-389
@@ -2,395 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v2.0.2
|
||||
|
||||
**Bugfixes**:
|
||||
- OIDC: `groupsClaim` value is always included in requested scopes and falls back to the UserInfo endpoint when the ID token omits the groups claim.
|
||||
- Windows: fix backslash duplication in navigation URLs, folder sizes showing 4 KB, and download/preview failures (#2815, #2816)
|
||||
- Wrong extension in the 'new database was created popup (#2817) (#2824)
|
||||
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
|
||||
- OnlyOffice is inaccessible on password-protected shares (#2811)
|
||||
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
|
||||
|
||||
## v2.0.1
|
||||
|
||||
**Security**:
|
||||
- [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds
|
||||
- [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo
|
||||
- [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu)
|
||||
- [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec
|
||||
- [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209
|
||||
|
||||
**Notes**:
|
||||
- GroupMap `SyncUserGroups` fix so JWT/OIDC/LDAP group memberships survive restart (#2742).
|
||||
- Session renew is handled by client keep-alive. removed per-request `X-Renew-Token` header handling.
|
||||
- Improvements to document thumbnail generation performance.
|
||||
- behavior changes for typing to select files in listing view.
|
||||
- next/previous buttons don't hide automatically on photos (#2767)
|
||||
- added more actions available in advanced search (#2776)
|
||||
|
||||
**Bugfixes**:
|
||||
- GroupMap mutations (`SyncUserGroups`, add/remove group members) are write-through to SQL so JWT/OIDC/LDAP group memberships survive restart (#2742).
|
||||
- `defaultEnabled` behavior change/fix so defaultEnabled means its always added to users on startup and login. (updated docs too)
|
||||
- Long uploads/downloads no longer lose the session mid-transfer: proactive session JWT keep-alive renews before expiry (#2638).
|
||||
- Disable auto logout/session expiry if there are active file transfers going (#2638)
|
||||
- upload options get cut off on mobile (#2685)
|
||||
- Uploading image: image gets cut-off (#2765)
|
||||
- Process aborts (not just 500) during PDF thumbnail generation (#2763)
|
||||
- PDF preview generation prevents file uploads from completing (#2752)
|
||||
- Brings back double-tap to zoom images that was mistakenly removed.
|
||||
- multiple embedded subtitles with same language bug (#2756)
|
||||
- gallery view download button missing (#2767)
|
||||
- fixed HEIC rotation regression from v1.5.x
|
||||
- fixed raw image preview regression from v1.5.x
|
||||
- html viewer takes full height
|
||||
- Members without download permission receive 403 when opening text-based files despite OnlyOffice preview being enabled (#2777)
|
||||
|
||||
## v2.0.0
|
||||
|
||||
This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary, and the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert legacy config.
|
||||
|
||||
**Breaking Changes**:
|
||||
- Removed: `GET /api/raw` and `GET /public/api/raw` download routes — use `/api/resources/download` instead.
|
||||
- Removed: `/share/…` URL redirect to `/public/share/…` — use `/public/share/…` directly.
|
||||
- Removed: singular `source` search api param (use `sources`), bare `scope` paths without `sourceName:` prefix, and `glob` / `useGlob` aliases (use `useWildcard`).
|
||||
- Removed `config.conditionals`, source-level `indexingIntervalMinutes` (indexing always uses adaptive scheduling), and deprecated rule fields `fileNames` / `folderNames` / top-level `hidden` — use `config.rules` with `fileName`, `folderName`, and `ignoreHidden` on rules. See [Exclusion rules](https://filebrowserquantum.com/en/docs/user-guides/general-configuration/exclusion-rules/).
|
||||
- Removed: deprecated `userDefaults` config formats (nested and flat) — use the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert before upgrading.
|
||||
- Changed: `PUT /api/users` moved to the more appropriate `PATCH` method and requires specifying `which` in the body. Blank or `all` values are rejected.
|
||||
- Changed: http related config options in `server` config key moved to `http` config key. See [HTTP settings](https://filebrowserquantum.com/en/docs/configuration/http/).
|
||||
- Changed (reverse proxy): `http.trustedHeaders` (v1.5.x list) removed — use `http.trustProxyHeaders: true` when behind nginx, Traefik, or Caddy. When enabled, FileBrowser honors `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-For`, and `X-Real-IP` for client IP, cookies, OIDC callbacks, WebAuthn, share URLs, rate limiting, and activity logs. Default is `false` (direct connection values). The [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) converts v1 `trustedHeaders` lists to `trustProxyHeaders: true`. See [Reverse proxy](https://filebrowserquantum.com/en/docs/getting-started/reverse-proxy/) and [HTTP trustProxyHeaders](https://filebrowserquantum.com/en/docs/configuration/http/#trustproxyheaders).
|
||||
- Changed: `FILEBROWSER_DATABASE` environment variable — use `FILEBROWSER_DATABASE_PATH` instead. See [Environment variables](https://filebrowserquantum.com/en/docs/reference/environment-variables/) and [Server settings](https://filebrowserquantum.com/en/docs/configuration/server/).
|
||||
- Changed: Moved stream api to `/api/media/stream`. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
|
||||
- Changed: CLI user management — canonical commands are `user set <username> --password [value]` and `user promote <username>`; `set -u username,password` is deprecated. See [CLI reference](https://filebrowserquantum.com/en/docs/reference/cli/).
|
||||
|
||||
|
||||
**New Features**:
|
||||
- View grant mechanism to distinguish between UI viewing and download. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
|
||||
- granular per-source file permissions (view, download, modify, create, delete) with automatic migration from global permissions
|
||||
- per-source defaults configurable in `settings > access management`
|
||||
- `view` permission is automatically set to true unless explicitly set to false. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
|
||||
- can be enforced for all users. See [Access control overview](https://filebrowserquantum.com/en/docs/access-control/access-control-overview/).
|
||||
- New activity logs for user activity.
|
||||
- charts and historical data
|
||||
- export to csv reports
|
||||
- Media player improvements:
|
||||
- Refreshed playback queue UI: Supports thumbnails, stored into session storage, and has a "clear queue" button (#2575) (#2600).
|
||||
- Loop now has 3 states (off/single/all) and neither of them will clear the existing queue (#2600).
|
||||
- New "Audio visualizer" for audio files (desktop-only), you can configure some basic things to your taste (#2575) (#2620).
|
||||
- The current state of the audio panel now is stored into local storage.
|
||||
- More gestures: Swipe up to enter/exit fullscreen, long-press to change playback speed, single tap to pause (#2575).
|
||||
- Videos now will resume fullscreen and PiP when navigating (queue auto-navigation, swipes gestures or next/previous) (#2649).
|
||||
- Added `F4` shortcut to refresh the current directory and metadata (#2600).
|
||||
- opt-in feature to send deployment analytics to filebrowser quantum developer servers
|
||||
- anonymized with a viewer so users can see what info would be sent.
|
||||
- if opt-in, every month a snapshot of your deployment config would be sent to developer servers
|
||||
- this will help me know what features are being used and what versions everyone is on over time. I will also provide a public dashboard with this information in the future.
|
||||
- WebDAV now supports set modification time via the `X-OC-Mtime` header for clients that support it (#2626). See [WebDAV docs](https://filebrowserquantum.com/en/docs/features/webdav/).
|
||||
- Copy operations now preserve their original modification times (#2642) (#2647):
|
||||
- WebUI preserves both, files and directories.
|
||||
- WebDAV `COPY` preserves modification times only for files, is limitation we have with webdav.
|
||||
- User default enhancements
|
||||
- Config `userDefaults` seeds SQLite on first run; only fields explicitly set in config stay locked in **Settings → User defaults** (other defaults remain editable)
|
||||
- Added administrator controls for universal user defaults and enforced preferences in `settings > user management > user defaults`.
|
||||
- Added configurable default file permissions per source in `settings > access management`.
|
||||
- Added a User Defaults editor for account, permission, and profile preferences in the edit/create user prompt. See [User management](https://filebrowserquantum.com/en/docs/configuration/users/).
|
||||
- Database env var rename: `FILEBROWSER_DATABASE` is removed (startup fails if set). Use `FILEBROWSER_DATABASE_PATH` (default `filebrowser.sqlite`) or `server.database.path` in config. See [Environment variables](https://filebrowserquantum.com/en/docs/reference/environment-variables/) and [Server settings](https://filebrowserquantum.com/en/docs/configuration/server/).
|
||||
- CLI: `user set` with `--password` (inline value, interactive prompt on TTY, or piped stdin); `user promote` for admin grant without password reset. See [CLI reference](https://filebrowserquantum.com/en/docs/reference/cli/).
|
||||
|
||||
**Notes**:
|
||||
- v2.x.x uses a new write-through backend state management. Changes go through a fast memory layer and also write changes to database to stay in sync. See [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/).
|
||||
- CLI server start (`./filebrowser`), `setup`, `version`, and `set rule` syntax unchanged; see [CLI docs](https://filebrowserquantum.com/en/docs/reference/cli/)
|
||||
- new dropdown and input styles
|
||||
- swipe gestures to dismiss notifications (#2672)
|
||||
- user updates are more granular, don't include entire user payload.
|
||||
- `user.id` has been moved to a backend property and all frontend apis now query users by username. Swagger has been updated. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
|
||||
- removed legacy and deprecated properties from API responses and generated config output
|
||||
- `/api/media/stream` is audio/video only (range-based chunking). Non-media inline viewing uses `GET /api/resources/view`. Both endpoints use the same `viewToken` from file metadata. See [API reference](https://filebrowserquantum.com/en/docs/reference/api/).
|
||||
- removed exiftool as an optional helper, always built with the supported libraries (requires 64 bit os)
|
||||
- If migration issues arise, see [Migration troubleshooting](https://filebrowserquantum.com/en/docs/getting-started/migration/troubleshooting/).
|
||||
- default browser media player option removed, always uses themed plyr
|
||||
- [docker] upgraded ffmpeg from 8.1.2 to 9.0
|
||||
|
||||
## v1.5.5
|
||||
|
||||
**BugFixes**:
|
||||
- fix uncustomized (minimal) API tokens creation needed by webdav clients (#2503)
|
||||
|
||||
## v1.5.4
|
||||
|
||||
**Notes**:
|
||||
- PWA installation name now capped at 30 characters instead of 12 (#2699)
|
||||
|
||||
**BugFixes**:
|
||||
- fix forward slash blocked in text fields when the search shortcut listener intercepts `/` (#2696)
|
||||
|
||||
## v1.5.3
|
||||
|
||||
**BugFixes**:
|
||||
- fix probe canShare with the real file, not a fixed text/plain stand-in (#2664)
|
||||
- When the logout button is pressed, the user is redirected to an invalid URL that does not honor baseURL / externalUrl (#2657)
|
||||
- fix redirect to login when an authenticated request returns 401 (expired session)
|
||||
|
||||
## v1.5.2
|
||||
|
||||
**Notes**:
|
||||
- [docker] upgraded ffmpeg version 8.1.1 to 8.1.2
|
||||
|
||||
**BugFixes**:
|
||||
- Fixed OnlyOffice reopening an older editor state after a document is modified (#2633) (#2578).
|
||||
- Cannot "Reset and generate new two-factor code" to reset the TOTP for a user (#2399) (#2641).
|
||||
|
||||
## v1.5.1
|
||||
|
||||
**BugFixes**:
|
||||
- fix http config section being dropped so trustedHeaders and disableRateLimit apply (#2602) (#2560)
|
||||
- fix upload shares with password (#2589) (#2573)
|
||||
- fix token when returning from preview on shares with pass (#2588) (#2465)
|
||||
- fix share undefined url after editing (#2567) (#2523)
|
||||
## v1.5.0
|
||||
|
||||
**New Features**:
|
||||
- Added basic html viewer with relative reference support (#2522)
|
||||
- Shares can have pinned files, requires a user to have edit access to share (#2522)
|
||||
- Enhanced search
|
||||
- now uses "lazy" match by default. (#2509)
|
||||
- added missing `case sensitive` option in the UI
|
||||
- Progressive Web App (PWA) improvements
|
||||
- restored install prompt with sidebar install button (#2086)
|
||||
- camera and video capture buttons on upload (mobile-friendly `capture` inputs)
|
||||
- send files to other apps via the Web Share API (`Send to app` in the context menu)
|
||||
- App notifications for file operations (#2478)
|
||||
- optional browser notifications when uploads, chunked downloads, move/copy, or failures finish while the tab is in the background
|
||||
- single on/off toggle in Notifications settings, stored in browser local storage
|
||||
|
||||
**Notes**:
|
||||
- [docker] upgraded ffmpeg version 8.1 to 8.1.1
|
||||
|
||||
**BugFixes**:
|
||||
- fixed PWA manifest `scope` and `id` so install works when the app is served under a base URL
|
||||
- installing a public share as a PWA now opens the share URL instead of the site root (#2302)
|
||||
|
||||
## v1.4.4
|
||||
|
||||
**BugFixes**:
|
||||
- Pinning/Unpinning file resets users' scope permission (#2532)
|
||||
|
||||
## v1.4.3
|
||||
|
||||
**Security**:
|
||||
- [High] Path traversal in subtitle handler allows any authenticated user to read arbitrary files (GHSA-vvp7-h4fj-m28w)
|
||||
|
||||
**New Features**:
|
||||
- pinned files/folders (#2396) (#2510)
|
||||
- Markdown Image relative reference support (#2355)
|
||||
- Add an option to create a new folder for unarchiving (#2338)
|
||||
|
||||
**Notes**:
|
||||
- added fallback to show text in notification if copy fails (#2517)
|
||||
- added `alt`+`arrow up` shortcut as alias of `backspace` to go into parent directory (#2501) (#2521)
|
||||
- added `alt`+`arrow down` shortcut as alias of `enter` to open files in Listing View (#2501) (#2521)
|
||||
- updated help menu with better translations
|
||||
- migrate i18n to v11 (#2472) (#2504)
|
||||
- migrate eslint (#2459)
|
||||
- improved preview cancellation to improve performance when navigating UI.
|
||||
|
||||
**BugFixes**:
|
||||
- fix lyrics regex parsing (#2505)
|
||||
- fix folder previews issue (#2487) (#2492)
|
||||
- fix accidental exit on images while using gestures (#2508)
|
||||
- navigating using the up/down arrow keys in Listing View was "jumping randomly" in some view modes (#2521)
|
||||
- socket field in config.yaml is ignored (#2497)
|
||||
- fix keep opened file selected after closing its preview @anpryl (#2515)
|
||||
|
||||
|
||||
## v1.4.2
|
||||
|
||||
**Security**:
|
||||
- [Moderate] Add Rate Limiting on Authentication Endpoint Enables Brute Force Attacks (GHSA-r4v7-6wcg-ghj5)
|
||||
- [Critical] Path traversal in public share PATCH allows file ops outside shared directory -- thanks @Revanth011 and @fg0x0 (GHSA-qqqm-5547-774x)
|
||||
|
||||
**New Features**:
|
||||
- read-only source configuration via `source.config.readOnly: true` (#2438)
|
||||
|
||||
**Notes**:
|
||||
- auth rate limiting can be disabled via `auth.disableRateLimit`
|
||||
- updated share hash middleware (#2443)
|
||||
- updated source info popup to include private and readOnly properties
|
||||
|
||||
**BugFixes**:
|
||||
- Logout from share page now redirects to the share instead of `/Login` again. (#2245)
|
||||
- `This location cannot be reached` error when navigating with FileTree in shares. (#2245)
|
||||
- Fix FileTree rename and move actions in previews. (#2245)
|
||||
- Delete prompt not showing date and thumbnails in some previews. (#2245)
|
||||
- fix path slash issue on windows (#2451) (#2433) (#2419)
|
||||
- Always force url rewrite for onlyoffice internal URL. Fixes Error saving with OnlyOffice (#2450)
|
||||
- Overriding a Deny with an Allow not working (#2405)
|
||||
|
||||
## v1.4.1
|
||||
|
||||
**Security**:
|
||||
- Fix critical: unauthenticated user can view source info
|
||||
|
||||
## v1.4.0
|
||||
|
||||
**New Features**:
|
||||
- Add Passkey (WebAuthn) support for passwordless authentication (#2287) (#2379) -- thanks @juansoler
|
||||
- Lyrics support for audio files (#2360):
|
||||
- Updated UI for audio previews!
|
||||
- The lyrics and the player gets syncronized in UI with the audio player.
|
||||
- Supports embedded lyrics (ID3v2 USLT, flac, ogg, clyr) and sidecar `.lrc` files with the same name as the audio file.
|
||||
- The `Q` shortcut in audio files will toggle the new panel visibility.
|
||||
- `E` shortcut to change between tabs of the panel if it's open.
|
||||
- Api log filtering support via `apiFilter` option for logging.
|
||||
- regex filter that excludes matching full api paths from being logged. (eg. `/user\?id\=self`) Defaults to `^/health|^/favicon.ico|^/static|^/public/static`
|
||||
- Add config option to disable /health endpoint logging (#2291)
|
||||
- More shortcuts (#2300)
|
||||
- `CTRL+F1` switch to normal view.
|
||||
- `CTRL+F2` switch to gallery view.
|
||||
- `CTRL+F3` switch to list view.
|
||||
- `Double click` on empty space to select all items.
|
||||
- Option to hide certain files in UI by extension (#2403) (#2277)
|
||||
- Additional search features
|
||||
- wildcard search similar to regex, but limited to sqlite GLOB syntax (#769)
|
||||
- advanced search tool that shows all results in a listing thats sortable and actionable like a normal listing (#1051)
|
||||
- advanced search supports multiple custom scopes per search
|
||||
- multiple search terms with AND or OR logic support
|
||||
|
||||
**Notes**:
|
||||
- Enhanced indexing scheduler which doesn't wake the disk as often.
|
||||
- New API route `media/lyrics` used to fetch and parse lyrics (embedded or from `.lrc` sidecar) (#2360)
|
||||
- Swiping down gesture in fullscreen videos exit fullscreen instead of close preview.
|
||||
- Improved styles for path selection and tables
|
||||
- Improved style of drag and drop into listing view (#2407)
|
||||
- Edit Sidebar links has new "show tools in sidebar" toggle and all users have this enabled by default. can be disabled via for new users `userDefaults.sidebar.showTools: false`
|
||||
- Update user defaults ordering (#1140)
|
||||
- Save view modes and sizes into local storage instead of db (#2301)
|
||||
|
||||
**BugFixes**:
|
||||
- Blue overlay when using gestures in video files on mobile (#2360)
|
||||
- Playback queue wasn't updating when changing of folder (#2360)
|
||||
- Added missing `UserDefaults.listing.showCopyPath` option to config file (#2364)
|
||||
- Delete prompt thumbnail didn't worked when using it with the `delete` shortcut. (#2407)
|
||||
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
|
||||
- Drag and drop style state was stuck until page reload (#2407)
|
||||
- Navigate close settings shows "something went wrong" (#2047)
|
||||
|
||||
## v1.3.10
|
||||
|
||||
**Notes**:
|
||||
- When indexing is disabled for a source, the usage will always reported as partition size.
|
||||
- removed deprecated `source.config.disableIndexing`, see [rules](https://filebrowserquantum.com/en/docs/advanced/source-configuration/conditional-rules/#disable-indexing)
|
||||
|
||||
**BugFixes**:
|
||||
- Disable index option not working (#2385)
|
||||
|
||||
## v1.3.9
|
||||
|
||||
**Security**:
|
||||
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
|
||||
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
|
||||
|
||||
**BugFixes**:
|
||||
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
|
||||
|
||||
## v1.3.8
|
||||
|
||||
**BugFixes**:
|
||||
- Quick download icon style after icon change.
|
||||
- Missing error popup for resource creatoin actions (upload/create)
|
||||
- EnforcedOtp login failure until restart (#2330)
|
||||
- Thumbnails for Folders only display sporadically (#2353)
|
||||
- Unwanted user scope change for users with non-default scopes (#2347)
|
||||
- Fix sidebar source info totals (#2321) (#2322) (#982)
|
||||
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
|
||||
|
||||
## v1.3.7
|
||||
|
||||
**Notes**:
|
||||
- creating/deleting password-based user requires reauthentication (#2112)
|
||||
|
||||
**BugFixes**:
|
||||
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
|
||||
- No Loginfields shown if password authentification is set to false (#2331)
|
||||
|
||||
## v1.3.6
|
||||
|
||||
**BugFixes**:
|
||||
- file watcher not working for files and not respecting line count (#2314)
|
||||
- "source" type link shows up multiple times when adding sidebar links if user has multiple sources.
|
||||
- JWT ES256 results in error (#2188)
|
||||
- single file shares not loading editor text content (#2122)
|
||||
|
||||
## v1.3.5
|
||||
|
||||
**Notes**:
|
||||
- any password change or admin user property change for a password user requires reauthentication (#2112)
|
||||
|
||||
**BugFixes**:
|
||||
- PWA icon fixes (#2292)
|
||||
- deny-rule'd folders visible in directory listings (regression from v1.2.4-stable) (#2295)
|
||||
- OTP and password requirement fixes (#2112) (#2263)
|
||||
- CLI not picking up config properly
|
||||
- Storage usage numbers on first load adjusted.
|
||||
- Some items in userDefaults are not setting defaults for new users properly #2278
|
||||
- Fix copy to clipboard and simplify code (#2281) (#2274)
|
||||
- Fix search shortcut `/` and pdf previews (#2307)
|
||||
- Special permissions bit not set when declared in createDirectoryPermission (#2283)
|
||||
- Execute permission altered after editing sh script with FB Quantum (#2309)
|
||||
- Source is red but working (#2289)
|
||||
|
||||
## v1.3.4
|
||||
|
||||
**New Features**:
|
||||
- Epub placementg url anchors to bookmark a specific location on the doc.
|
||||
- Add Requirement for Current Password When Changing Account Password (#2112)
|
||||
- Copy file path to clipborad through right click (#2204)
|
||||
|
||||
**Notes**:
|
||||
- user scope editing has path picker and filesystem validation
|
||||
|
||||
**BugFixes**:
|
||||
- Swiping media files doesn't respect Playback Queue (#2243)
|
||||
- Audio metadata and album thumbnails doesn't work (#2236)
|
||||
- Search spinner wouldn't show up sometimes while searching
|
||||
- The default landing page source should always be the first source in the sidebar, that wasn't always respected.
|
||||
|
||||
## v1.3.3
|
||||
|
||||
**New Features**:
|
||||
- Copy/paste files and folders (`CTRL+C`/`CTRL+V`) from other apps (win explorer, thunar, finder, etc) to upload them directly (#2197)
|
||||
- Caption font size can be adjusted in video settings
|
||||
- Ability to adjust the startup check method for sql database via `server.startupIntegrityCheck` (#2221)
|
||||
- Status bar for editor and markdown viewer (#2226)
|
||||
|
||||
**Notes**:
|
||||
- `.fbx` 3d model files added to supported previews.
|
||||
- removed upx compression on docker image (#2193)
|
||||
- videos double tap to fast-forward and rewind added.
|
||||
- Adjust Image album swipe behavior (#2068)
|
||||
- swipe animation for next/previous
|
||||
- swipe down on image to close and go to parent folder.
|
||||
- supports videos
|
||||
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
|
||||
- The Icons in the UI were updated! (#2203)
|
||||
- More supported icons in the Icon Picker tool.
|
||||
- More file types have new icons across all the listings (such as `.md`, `.apk`, etc).
|
||||
- `.fbx` 3d model files added to supported previews.
|
||||
- removed upx compression on docker image (#2193)
|
||||
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
|
||||
- deprecated `source.config.CreateUserDir`, now its always `true`. If a user directory doesn't exist it will get created empty.
|
||||
- `CTRL`+`Mouse Wheel` shortcut to change listing size. Also for change font size in editor (#2227)
|
||||
|
||||
**BugFixes**:
|
||||
- Archives don't preserve file metadata (#2063)
|
||||
- Chunked upload setting fails: "0" does not disable chunking as expected (#2202)
|
||||
- Incorrect Chinese labels for Upload controls in Quantum UI (#2191)
|
||||
- Chunked download stops after first chunk, add message explaining (#2074)
|
||||
- Esc to Cancel and Enter to confirm popup (#2079)
|
||||
- Language-tagged subtitle files not detected for videos (#2199)
|
||||
- Unable to use proxy auth since 1.3.0-beta (#2173)
|
||||
- Updating setting on a LDAP user turns the "Login Method" to password. Thus preventing further logins. (#2179)
|
||||
|
||||
## v1.3.2-beta
|
||||
|
||||
**Security**:
|
||||
@@ -421,6 +32,7 @@ This version represents the most significant change to date. It **requires** bot
|
||||
- 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**:
|
||||
|
||||
+69
-8
@@ -5,13 +5,13 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.25+** (see `backend/go.mod`)
|
||||
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
|
||||
- **Node.js 20.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
|
||||
- **Docker** (optional, for containerized development)
|
||||
- **Git** especially improtant on windows -- needed for bash support.
|
||||
|
||||
### Optional Tools
|
||||
|
||||
- **ffmpeg/ffprobe**: For media features (subtitles, thumbnails, heic)
|
||||
- **ffmpeg**: For media features (subtitles, thumbnails, heic)
|
||||
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
|
||||
- macOS: `brew install ffmpeg`
|
||||
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
|
||||
@@ -42,12 +42,11 @@ make dev
|
||||
|
||||
### Backend (Go)
|
||||
- **Entry Point**: `backend/main.go` → `backend/cmd/`
|
||||
- **HTTP Server**: `backend/internal/web/` - API routes, middleware, auth
|
||||
- **Storage**: SQLite via `backend/internal/database/`
|
||||
- **Authentication**: Multiple providers in `backend/internal/auth/`
|
||||
- **Indexing**: Real-time search in `backend/pkg/indexing/`
|
||||
- **Previews**: Image/video/document generation in `backend/internal/preview/`
|
||||
- **Activity**: Query, recording, and export logic in `backend/internal/activity/`
|
||||
- **HTTP Server**: `backend/http/` - API routes, middleware, auth
|
||||
- **Storage**: BoltDB via `backend/database/storage/`
|
||||
- **Authentication**: Multiple providers in `backend/auth/`
|
||||
- **Indexing**: Real-time search in `backend/indexing/`
|
||||
- **Previews**: Image/video/document generation in `backend/preview/`
|
||||
|
||||
### Frontend (Vue.js + TypeScript)
|
||||
- **Framework**: Vue 3 + Vite + TypeScript
|
||||
@@ -91,6 +90,18 @@ make test-playwright # E2E tests in Docker
|
||||
|
||||
E2E tests run with three authentication modes: standard auth, no auth, and proxy auth.
|
||||
|
||||
### Coverage & Performance
|
||||
```bash
|
||||
cd backend
|
||||
./run_check_coverage.sh # Coverage report with HTML output
|
||||
./run_benchmark.sh # Benchmarks
|
||||
```
|
||||
|
||||
**Code Coverage:**
|
||||
- View report: Open `backend/coverage.html` after running coverage script
|
||||
- CI enforces coverage for critical packages
|
||||
- Use `go test -cover` for quick package coverage
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (Go)
|
||||
@@ -145,3 +156,53 @@ type(scope): description
|
||||
|
||||
Types: feat, fix, docs, refactor, test, chore
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Check the [Project Roadmap](https://github.com/users/gtsteffaniak/projects/4/views/2) to see issues sorted by priority. This helps you understand what features are planned and where you can contribute most effectively.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Build failures:**
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && rm -rf node_modules && npm install
|
||||
|
||||
# Backend
|
||||
cd backend && go mod tidy && go clean -modcache
|
||||
```
|
||||
|
||||
**Authentication Issues:**
|
||||
|
||||
Always first enable debug logging in your config file if you have issues.
|
||||
|
||||
1. **OIDC Login Fails:**
|
||||
- Check redirect URLs match your config
|
||||
- Verify OIDC provider settings
|
||||
- Enable debug logs to see auth flow
|
||||
- Common issue: mismatched callback URLs
|
||||
|
||||
2. **Proxy Auth Not Working:**
|
||||
- Verify header names match config
|
||||
- Check nginx/reverse proxy passes headers
|
||||
- Test with: `curl -H "X-Auth: username" localhost:8080`
|
||||
- Enable `--log-level debug` to see headers
|
||||
|
||||
3. **TOTP Issues:**
|
||||
- Ensure server time is synchronized
|
||||
- Check QR code generation in logs
|
||||
- Test with authenticator app time settings
|
||||
- Database might have stale TOTP secrets
|
||||
|
||||
4. **Session/Cookie Problems:**
|
||||
- Clear browser cookies and localStorage
|
||||
- Verify cookie domain settings
|
||||
- Try incognito/private browsing mode
|
||||
|
||||
### Getting Help
|
||||
|
||||
- **Wiki**: [Project Wiki](https://github.com/gtsteffaniak/filebrowser/wiki)
|
||||
- **Issues**: [GitHub Issues](https://github.com/gtsteffaniak/filebrowser/issues)
|
||||
- **PR Template**: See `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
@@ -1,24 +1,25 @@
|
||||
<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: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
|
||||
|
||||
: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
|
||||
|
||||
@@ -38,23 +39,21 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
|
||||
2. ✅ Login support for OIDC, LDAP, JWT, password + 2FA, and proxy.
|
||||
3. ✅ Beautiful, Responsive, and Customizable user interface.
|
||||
4. ✅ Streamlined configuration via `config.yaml` config file.
|
||||
5. ✅ Efficient search
|
||||
5. ✅ Ultra-efficient Sqlite [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing)
|
||||
- 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**, and **3D models**.
|
||||
- Faster and more responsive views with animations.
|
||||
- **Folder sizes** are displayed and support thumbnails
|
||||
- Folder sizes are displayed and support thumbnails
|
||||
- Navigating remembers the last scroll position.
|
||||
- WebDAV support
|
||||
- Granular permissions
|
||||
7. ✅ Highly configurable and [customizable sharing options](https://filebrowserquantum.com/en/docs/shares/options/)
|
||||
- share expiration time
|
||||
- users who can access share (including anonymous)
|
||||
- styling and themes
|
||||
- file viewing, editing, and uploading permissions
|
||||
8. ✅ 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.
|
||||
@@ -69,22 +68,24 @@ FileBrowser Quantum differs significantly from the original version. Many of the
|
||||
The UI has a simple three-component navigation system:
|
||||
|
||||
1. (Left) Multi-action button with slide-out panel.
|
||||
2. (Middle) The powerful search bar / 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 | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
@@ -124,10 +125,10 @@ Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
|
||||
activity log | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
Starred/pinned files | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
Starred/pinned files | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ |
|
||||
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
|
||||
+14
-14
@@ -1,35 +1,34 @@
|
||||
FROM gtstef/ffmpeg:9.0-decode AS ffmpeg
|
||||
FROM gtstef/ffmpeg:8.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/
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN cd frontend && npm i --maxsockets 1
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm i --maxsockets 1
|
||||
COPY ./frontend/ ./
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
|
||||
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
|
||||
ENV FILEBROWSER_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,12 @@ 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" ]
|
||||
# exposing default port for auto discovery.
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
|
||||
@@ -9,14 +9,15 @@ 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/"]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
# generate duplicate 1.1MB files
|
||||
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
|
||||
|
||||
# Run tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser 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
|
||||
@@ -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 sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -2,21 +2,13 @@ FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests/", "./tests" ]
|
||||
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
|
||||
COPY [ "./frontend/", "./" ]
|
||||
|
||||
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/"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
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" ]
|
||||
RUN mkdir -p ./http/dist
|
||||
COPY [ "./backend/http/embed/", "./http/dist/"]
|
||||
|
||||
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
|
||||
|
||||
@@ -9,8 +9,8 @@ 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 tests
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,15 +2,14 @@ FROM ghcr.io/gtsteffaniak/playwright-base:chromium
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./frontend/tests", "./tests" ]
|
||||
COPY [ "./_docker/src/screenshots/frontend/", "./" ]
|
||||
COPY [ "./frontend/", "./" ]
|
||||
|
||||
WORKDIR /app/backend/
|
||||
COPY [ "./backend/filebrowser", "./"]
|
||||
COPY [ "./backend/filebrowser*", "./"]
|
||||
COPY [ "./_docker/src/screenshots/backend/*", "./"]
|
||||
|
||||
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/"]
|
||||
|
||||
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
|
||||
|
||||
+11
-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/
|
||||
WORKDIR /app
|
||||
COPY ./frontend/package.json ./
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN cd frontend && npm i
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm i
|
||||
COPY ./frontend/ ./
|
||||
RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_DATABASE_PATH="/home/filebrowser/data/database.sqlite"
|
||||
ENV FILEBROWSER_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,7 +32,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/build/backend/internal/web/dist/", "./internal/web/dist/" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
RUN [ "filebrowser", "version" ]
|
||||
# exposing default port for auto discovery.
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- ../frontend/generated:/app/frontend/generated
|
||||
entrypoint: ["/bin/sh", "-c", "./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots && npx playwright test --project light-screenshots"]
|
||||
build:
|
||||
dockerfile: ./_docker/Dockerfile.playwright-screenshots
|
||||
dockerfile: ./_docker/Dockerfile.playwright-local
|
||||
context: ../
|
||||
nginx-proxy-auth:
|
||||
image: nginx
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
server:
|
||||
<<: *server-main
|
||||
http:
|
||||
<<: *http-main
|
||||
frontend:
|
||||
<<: *frontend-main
|
||||
|
||||
@@ -14,6 +12,6 @@ userDefaults:
|
||||
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,19 +1,12 @@
|
||||
http:
|
||||
port: 8080
|
||||
server:
|
||||
port: 8080
|
||||
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,17 +30,19 @@ auth:
|
||||
groupsClaim: "mygroup"
|
||||
adminGroup: "admin"
|
||||
userDefaults:
|
||||
ui:
|
||||
darkMode: true
|
||||
account:
|
||||
disableSettings: false
|
||||
permissions:
|
||||
share: true
|
||||
listing:
|
||||
singleClick: false
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
video: true
|
||||
office: true
|
||||
highQuality: true
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: false
|
||||
permissions:
|
||||
admin: false
|
||||
modify: true
|
||||
share: true
|
||||
api: false
|
||||
download: true
|
||||
create: true
|
||||
@@ -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,18 +1,10 @@
|
||||
http:
|
||||
server:
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
config:
|
||||
defaultPermissions:
|
||||
view: true
|
||||
download: true
|
||||
modify: true
|
||||
create: true
|
||||
|
||||
auth:
|
||||
methods:
|
||||
@@ -29,8 +21,7 @@ frontend:
|
||||
disableEventThemes: true
|
||||
|
||||
userDefaults:
|
||||
listing:
|
||||
showHidden: true
|
||||
showHidden: true
|
||||
preview:
|
||||
image: true
|
||||
popup: true
|
||||
@@ -39,3 +30,6 @@ userDefaults:
|
||||
office: true
|
||||
models: true
|
||||
highQuality: true
|
||||
permissions:
|
||||
download: true
|
||||
create: true
|
||||
@@ -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
|
||||
@@ -1,10 +1,8 @@
|
||||
http:
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/files/"
|
||||
server:
|
||||
logging:
|
||||
- levels: "info|error|warning|debug"
|
||||
apiFilter: 'disabled'
|
||||
- levels: "info|error|debug"
|
||||
sources:
|
||||
- path: "../frontend/tests/playwright-files"
|
||||
name: "playwright"
|
||||
|
||||
@@ -18,7 +18,7 @@ export default defineConfig({
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: false,
|
||||
/* Retry on CI only */
|
||||
retries: 1,
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "line",
|
||||
@@ -53,6 +53,8 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'dark',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
{
|
||||
name: "light-screenshots",
|
||||
@@ -60,6 +62,8 @@ export default defineConfig({
|
||||
...devices["Desktop Chrome"],
|
||||
theme: 'light',
|
||||
},
|
||||
testMatch: /.*screenshots.spec.ts/,
|
||||
retries: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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:
|
||||
@@ -54,8 +47,6 @@ builds:
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
@@ -71,10 +62,8 @@ builds:
|
||||
- id: windows
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
# windows-gnu avoids zig rejecting Go's -tsaware when cross-compiling from macOS
|
||||
- CC=zig cc -target x86_64-windows-gnu -lc
|
||||
- CC=zig cc -target x86_64-windows
|
||||
tags:
|
||||
- analytics
|
||||
- mupdf
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
@@ -92,20 +81,16 @@ builds:
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
|
||||
# Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported)
|
||||
# Build configuration for macos without upx
|
||||
- id: macos
|
||||
ldflags: *ldflags
|
||||
main: main.go
|
||||
binary: filebrowser
|
||||
tags:
|
||||
- analytics
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
|
||||
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.
+11
-122
@@ -1,7 +1,6 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,12 +10,11 @@ import (
|
||||
"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 +88,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 +250,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 +268,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 +360,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 +376,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)
|
||||
@@ -600,11 +493,7 @@ func TestDeleteFilesRootProtection(t *testing.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++ {
|
||||
for iter := 1; iter <= raceStressIterations; iter++ {
|
||||
iter := iter
|
||||
t.Run(fmt.Sprintf("stress-iter-%d", iter), func(t *testing.T) {
|
||||
runDeleteFilesSubfolderWithRaceStress(t, iter)
|
||||
@@ -688,7 +577,7 @@ func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
|
||||
}
|
||||
|
||||
indexing.StopAllScanners()
|
||||
// Give scanners time to finish their cleanup (defer blocks in executeScan)
|
||||
// Give scanners time to finish their cleanup (defer blocks in tryAcquireAndScan)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
indexing.ClearTestIndices()
|
||||
t.Logf("iteration %d: cleaned scanners and indices", iter)
|
||||
+159
-350
@@ -1,66 +1,32 @@
|
||||
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/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"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/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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
var CheckPermissionsFunc = checkPermissionsImpl
|
||||
|
||||
// processDirectoryMetadata extracts metadata for audio/video files in directories
|
||||
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
|
||||
@@ -73,23 +39,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,28 +119,16 @@ 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
|
||||
}
|
||||
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)
|
||||
func CheckPermissions(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
return CheckPermissionsFunc(opts, access, user)
|
||||
}
|
||||
|
||||
// checkPermissionsImpl is the actual implementation of CheckPermissions
|
||||
func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service) (string, string, error) {
|
||||
func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
|
||||
if access == nil {
|
||||
return "", "", fmt.Errorf("access not provided")
|
||||
}
|
||||
if user == nil {
|
||||
return "", "", fmt.Errorf("user not provided")
|
||||
}
|
||||
@@ -149,19 +151,18 @@ func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service)
|
||||
return "", "", fmt.Errorf("user has no access to source: %v", opts.Source)
|
||||
}
|
||||
|
||||
safePath, err := utils.SanitizePath(opts.Path)
|
||||
safePath, err := utils.SanitizeUserPath(opts.Path)
|
||||
if err != nil {
|
||||
return "", "", errors.ErrAccessDenied
|
||||
}
|
||||
|
||||
indexPath := utils.JoinScopedIndexPath(userScope, safePath)
|
||||
parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true)
|
||||
if err != nil {
|
||||
// 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 "", "", errors.ErrAccessDenied
|
||||
}
|
||||
if !s.accessPermitted(idx.Path, parsedPath, user.Username) {
|
||||
return indexPath, "", errors.ErrAccessDenied
|
||||
}
|
||||
return indexPath, userScope, nil
|
||||
}
|
||||
|
||||
@@ -170,66 +171,36 @@ type Items struct {
|
||||
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) {
|
||||
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (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
|
||||
indexPath, _, err := CheckPermissions(opts, access, user)
|
||||
if err != nil {
|
||||
return items, err
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
if idx == nil {
|
||||
return items, fmt.Errorf("could not get index: %v ", opts.Source)
|
||||
}
|
||||
infoReq := indexing.FileInfoRequest{
|
||||
|
||||
info, err := idx.GetFileInfo(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 {
|
||||
if err := access.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)
|
||||
}
|
||||
}
|
||||
@@ -242,21 +213,18 @@ func getDirItemsImpl(opts utils.FileOptions, user *users.User, s *Service) (Item
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
var FileInfoFasterFunc = fileInfoFasterImpl
|
||||
|
||||
func FileInfoFaster(opts utils.FileOptions, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return FileInfoFasterFunc(opts, user)
|
||||
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
return FileInfoFasterFunc(opts, access, user, share)
|
||||
}
|
||||
|
||||
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
|
||||
func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*iteminfo.ExtendedFileInfo, error) {
|
||||
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*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
|
||||
indexPath, userScope, err := CheckPermissions(opts, access, user)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
// Get index
|
||||
idx := indexing.GetIndex(opts.Source)
|
||||
@@ -265,83 +233,47 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*
|
||||
}
|
||||
// Layer 2: INDEX RULES (global)
|
||||
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
|
||||
fileInfoReq := indexing.FileInfoRequest{
|
||||
info, err := idx.GetFileInfo(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)
|
||||
})
|
||||
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 {
|
||||
response.RealPath = filepath.Join(idx.Path, indexPath)
|
||||
response.Source = opts.Source
|
||||
|
||||
// Layer 3: FILTER CHILDREN (user access)
|
||||
// Remove child items THIS user can't access
|
||||
if info.Type == "directory" {
|
||||
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.RealPath = realPath
|
||||
} else {
|
||||
response.RealPath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
|
||||
}
|
||||
response.Source = opts.Source
|
||||
if user.Permissions.Share && opts.ShowSharedAttr {
|
||||
if share != nil && 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)
|
||||
file.IsShared = share.IsShared(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)
|
||||
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path, user.ID)
|
||||
}
|
||||
response.IsShared = s.pathIsShared(response.Path, idx.Path, user.ID)
|
||||
response.IsShared = share.IsShared(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)
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
normalizeFileInfoListing(response)
|
||||
}()
|
||||
defer finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
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")
|
||||
@@ -358,13 +290,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 +303,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,20 +313,22 @@ 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
|
||||
@@ -427,57 +358,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
|
||||
@@ -513,36 +397,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 {
|
||||
@@ -568,76 +429,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
|
||||
}
|
||||
|
||||
@@ -652,8 +463,8 @@ func DeleteFiles(source, absPath string, isDir bool) error {
|
||||
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)
|
||||
@@ -673,7 +484,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)
|
||||
@@ -689,11 +500,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)
|
||||
@@ -764,11 +575,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)
|
||||
@@ -790,7 +597,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
|
||||
@@ -801,7 +608,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)
|
||||
@@ -816,7 +623,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
|
||||
@@ -838,15 +645,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
|
||||
@@ -873,7 +681,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)
|
||||
@@ -884,7 +692,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
|
||||
@@ -971,21 +779,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
|
||||
@@ -998,11 +807,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",
|
||||
@@ -150,8 +151,7 @@ var types = map[string]string{
|
||||
".evy": "application/envoy",
|
||||
".exe": "application/x-msdownload",
|
||||
".dll": "application/x-msdownload",
|
||||
".bat": "application/x-msdos-program",
|
||||
".cmd": "application/x-msdos-program",
|
||||
".bat": "application/x-msdownload",
|
||||
".msi": "application/x-msdownload",
|
||||
".f77": "text/x-fortran",
|
||||
".f90": "text/x-fortran",
|
||||
@@ -228,6 +228,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",
|
||||
@@ -259,6 +260,7 @@ var types = map[string]string{
|
||||
".m1v": "video/mpeg",
|
||||
".m2a": "audio/mpeg",
|
||||
".m2v": "video/mpeg",
|
||||
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
|
||||
".m": "text/x-m",
|
||||
".man": "application/x-troff-man",
|
||||
".manifest": "text/cache-manifest",
|
||||
@@ -308,6 +310,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 +346,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",
|
||||
@@ -442,12 +446,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",
|
||||
@@ -602,52 +603,10 @@ var types = map[string]string{
|
||||
".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",
|
||||
".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",
|
||||
@@ -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 {
|
||||
+7
-101
@@ -12,45 +12,9 @@ import (
|
||||
var PermFile os.FileMode
|
||||
var PermDir os.FileMode
|
||||
|
||||
// SetFsPermissions sets create modes from Unix chmod(2)-style values (for example
|
||||
// values produced by strconv.ParseUint(s, 8, 32)). setuid, setgid, and sticky
|
||||
// bits must be converted for Go's os.FileMode; a plain cast from Unix octal is
|
||||
// incorrect for those bits.
|
||||
func SetFsPermissions(unixFileMode, unixDirMode uint32) {
|
||||
PermFile = unixModeToFileMode(unixFileMode)
|
||||
PermDir = unixModeToFileMode(unixDirMode)
|
||||
}
|
||||
|
||||
// EffectiveDirPerm returns [PermDir] if [SetFsPermissions] was called, otherwise 0o755. On Unix,
|
||||
// [os.Mkdir] with perm 0 fails with permission denied, so use this (or [PermFile]) for creates when
|
||||
// the process has not set globals yet.
|
||||
func EffectiveDirPerm() os.FileMode {
|
||||
if PermDir == 0 {
|
||||
return 0o755
|
||||
}
|
||||
return PermDir
|
||||
}
|
||||
|
||||
// EffectiveFilePerm returns [PermFile] if set, otherwise 0o644.
|
||||
func EffectiveFilePerm() os.FileMode {
|
||||
if PermFile == 0 {
|
||||
return 0o644
|
||||
}
|
||||
return PermFile
|
||||
}
|
||||
|
||||
func unixModeToFileMode(u uint32) os.FileMode {
|
||||
m := os.FileMode(u & 0777)
|
||||
if u&0o4000 != 0 {
|
||||
m |= os.ModeSetuid
|
||||
}
|
||||
if u&0o2000 != 0 {
|
||||
m |= os.ModeSetgid
|
||||
}
|
||||
if u&0o1000 != 0 {
|
||||
m |= os.ModeSticky
|
||||
}
|
||||
return m
|
||||
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
|
||||
PermFile = PermFileOctal
|
||||
PermDir = PermDirOctal
|
||||
}
|
||||
|
||||
// MoveFile moves a file from src to dst.
|
||||
@@ -113,7 +77,7 @@ func copySingleFile(source, dest string) error {
|
||||
defer src.Close()
|
||||
|
||||
// Create the destination directory if needed.
|
||||
err = os.MkdirAll(filepath.Dir(dest), EffectiveDirPerm())
|
||||
err = os.MkdirAll(filepath.Dir(dest), PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -139,21 +103,14 @@ func copySingleFile(source, dest string) error {
|
||||
// The file was copied successfully, so we continue
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
|
||||
}
|
||||
// Preserve source file mod time
|
||||
if err := os.Chtimes(dest, srcInfo.ModTime(), srcInfo.ModTime()); err != nil {
|
||||
logger.Debugf("Could not preserve modification time for %s: %v", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyDirectory handles copying directories recursively.
|
||||
func copyDirectory(source, dest string) error {
|
||||
srcInfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Create the destination directory.
|
||||
err = os.MkdirAll(dest, EffectiveDirPerm())
|
||||
err := os.MkdirAll(dest, PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -183,40 +140,8 @@ func copyDirectory(source, dest string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.Chtimes(dest, srcInfo.ModTime(), srcInfo.ModTime())
|
||||
}
|
||||
|
||||
// PreserveModTimes copies the mod times from src onto dst, this is used by the webdav COPY handler
|
||||
func PreserveModTimes(src, dst string) error {
|
||||
info, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return os.Chtimes(dst, info.ModTime(), info.ModTime())
|
||||
}
|
||||
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
logger.Debugf("Error accessing %s to set mod time: %v", p, err)
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
logger.Debugf("Error getting info for %s: %v", p, err)
|
||||
return nil
|
||||
}
|
||||
if err := os.Chtimes(filepath.Join(dst, rel), info.ModTime(), info.ModTime()); err != nil {
|
||||
logger.Debugf("Could not preserve modification time for %s: %v", filepath.Join(dst, rel), err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommonPrefix returns the common directory path of provided files.
|
||||
@@ -279,22 +204,3 @@ func ClearCacheDir(cacheDir string) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ClearDirectoryContents removes all files and subdirectories inside dir; dir itself remains.
|
||||
// If dir does not exist, returns nil.
|
||||
func ClearDirectoryContents(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package fileutils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCommonPrefix(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
paths []string
|
||||
want string
|
||||
}{
|
||||
"same lvl": {
|
||||
paths: []string{
|
||||
"/home/user/file1",
|
||||
"/home/user/file2",
|
||||
},
|
||||
want: "/home/user",
|
||||
},
|
||||
"sub folder": {
|
||||
paths: []string{
|
||||
"/home/user/folder",
|
||||
"/home/user/folder/file",
|
||||
},
|
||||
want: "/home/user/folder",
|
||||
},
|
||||
"relative path": {
|
||||
paths: []string{
|
||||
"/home/user/folder",
|
||||
"/home/user/folder/../folder2",
|
||||
},
|
||||
want: "/home/user",
|
||||
},
|
||||
"no common path": {
|
||||
paths: []string{
|
||||
"/home/user/folder",
|
||||
"/etc/file",
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for name, tt := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := CommonPrefix('/', tt.paths...); got != tt.want {
|
||||
t.Errorf("CommonPrefix() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -6,9 +6,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// Auther is the authentication interface.
|
||||
@@ -52,16 +52,10 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
|
||||
},
|
||||
}
|
||||
if !minimal {
|
||||
claim.Permissions = users.SanitizeTokenPermissions(perms)
|
||||
claim.Permissions = perms
|
||||
claim.BelongsTo = user.ID
|
||||
}
|
||||
signedClaims := jwt.Claims(claim)
|
||||
if minimal {
|
||||
// Sign only standard JWT claims. The full AuthToken struct always JSON-marshals
|
||||
// a zero Permissions object, which bloats the token past WebDAV client limits.
|
||||
signedClaims = claim.MinimalAuthToken
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, signedClaims)
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
|
||||
tokenString, err := token.SignedString([]byte(settings.Config.Auth.Key))
|
||||
if err != nil {
|
||||
return "", users.AuthToken{}, err
|
||||
@@ -0,0 +1,121 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// JSONAuth is a json implementation of an Auther.
|
||||
type JSONAuth struct {
|
||||
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
|
||||
}
|
||||
|
||||
// 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, getErr := userStore.Get(username)
|
||||
var passwordHash string
|
||||
if getErr != nil {
|
||||
passwordHash = utils.InvalidPasswordHash
|
||||
} else {
|
||||
passwordHash = user.Password
|
||||
}
|
||||
// always run checkPwd to prevent timing attacks
|
||||
err = utils.CheckPwd(password, passwordHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if getErr != nil {
|
||||
return nil, fmt.Errorf("unable to get user from store: %v", err)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
@@ -29,45 +29,39 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// mockUserBackend is a simple in-memory user storage for testing.
|
||||
type mockUserBackend struct {
|
||||
usersByUsername map[string]*users.User
|
||||
usersByID map[uint64]*users.User
|
||||
usersByID map[uint]*users.User
|
||||
}
|
||||
|
||||
func newMockUserBackend() *mockUserBackend {
|
||||
return &mockUserBackend{
|
||||
usersByUsername: make(map[string]*users.User),
|
||||
usersByID: make(map[uint64]*users.User),
|
||||
usersByID: make(map[uint]*users.User),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) GetBy(id uint64) (*users.User, error) {
|
||||
if user, ok := m.usersByID[id]; ok {
|
||||
return user, nil
|
||||
func (m *mockUserBackend) GetBy(id interface{}) (*users.User, error) {
|
||||
switch v := id.(type) {
|
||||
case string:
|
||||
if user, ok := m.usersByUsername[v]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, fmt.Errorf("user not found: %s", v)
|
||||
case uint:
|
||||
if user, ok := m.usersByID[v]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, fmt.Errorf("user not found: %d", v)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid id type")
|
||||
}
|
||||
return nil, fmt.Errorf("user not found: %d", id)
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) GetUserByID(id uint64) (users.User, error) {
|
||||
u, err := m.GetBy(id)
|
||||
if err != nil {
|
||||
return users.User{}, err
|
||||
}
|
||||
return *u, nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) GetUserByUsername(username string) (users.User, error) {
|
||||
if user, ok := m.usersByUsername[username]; ok {
|
||||
return *user, nil
|
||||
}
|
||||
return users.User{}, errors.ErrNotExist
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) Gets() ([]*users.User, error) {
|
||||
@@ -90,7 +84,7 @@ func (m *mockUserBackend) Update(u *users.User, adminActor bool, fields ...strin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserBackend) DeleteByID(id uint64) error {
|
||||
func (m *mockUserBackend) DeleteByID(id uint) error {
|
||||
if user, ok := m.usersByID[id]; ok {
|
||||
delete(m.usersByUsername, user.Username)
|
||||
delete(m.usersByID, id)
|
||||
@@ -99,8 +93,17 @@ func (m *mockUserBackend) DeleteByID(id uint64) error {
|
||||
return fmt.Errorf("user not found: %d", id)
|
||||
}
|
||||
|
||||
// setupTestUsers creates mock user persistence for timing tests.
|
||||
func setupTestUsers(t *testing.T) *mockUserBackend {
|
||||
func (m *mockUserBackend) DeleteByUsername(username string) error {
|
||||
if user, ok := m.usersByUsername[username]; ok {
|
||||
delete(m.usersByID, user.ID)
|
||||
delete(m.usersByUsername, username)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
// setupTestUsers creates a user storage with 2 valid test users
|
||||
func setupTestUsers(t *testing.T) *users.Storage {
|
||||
t.Helper()
|
||||
|
||||
// Reduce bcrypt cost for faster tests (default is 10, min is 4)
|
||||
@@ -119,20 +122,7 @@ func setupTestUsers(t *testing.T) *mockUserBackend {
|
||||
}
|
||||
|
||||
backend := newMockUserBackend()
|
||||
authSvc := auth.New(backend)
|
||||
auth.SetDefault(authSvc)
|
||||
t.Cleanup(func() {
|
||||
auth.SetDefault(nil)
|
||||
})
|
||||
users.SetUsernameToID(func(username string) (uint64, error) {
|
||||
if u, ok := backend.usersByUsername[username]; ok {
|
||||
return u.ID, nil
|
||||
}
|
||||
return 0, errors.ErrNotExist
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
users.SetUsernameToID(nil)
|
||||
})
|
||||
storage := users.NewStorage(backend)
|
||||
|
||||
// Create two valid users with hashed passwords
|
||||
password1Hash, err := utils.HashPwd("password123")
|
||||
@@ -146,42 +136,38 @@ func setupTestUsers(t *testing.T) *mockUserBackend {
|
||||
}
|
||||
|
||||
user1 := &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password1Hash,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password1Hash,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
|
||||
user2 := &users.User{
|
||||
ID: 2,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "testuser",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password2Hash,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
ID: 2,
|
||||
Username: "testuser",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: password2Hash,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
|
||||
err = backend.Save(user1, false, false)
|
||||
err = storage.Save(user1, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save user1: %v", err)
|
||||
}
|
||||
|
||||
err = backend.Save(user2, false, false)
|
||||
err = storage.Save(user2, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save user2: %v", err)
|
||||
}
|
||||
|
||||
return backend
|
||||
return storage
|
||||
}
|
||||
|
||||
// measureAuthTime measures the time taken to authenticate with given credentials
|
||||
func measureAuthTime(t *testing.T, auther auth.JSONAuth, username, password string) time.Duration {
|
||||
func measureAuthTime(t *testing.T, auther auth.JSONAuth, userStore *users.Storage, username, password string) time.Duration {
|
||||
t.Helper()
|
||||
|
||||
// Create a mock HTTP request
|
||||
@@ -192,7 +178,7 @@ func measureAuthTime(t *testing.T, auther auth.JSONAuth, username, password stri
|
||||
req.Header.Set("X-Password", url.QueryEscape(password))
|
||||
|
||||
start := time.Now()
|
||||
_, _ = auther.Auth(req, nil)
|
||||
_, _ = auther.Auth(req, userStore)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
return elapsed
|
||||
@@ -200,8 +186,10 @@ func measureAuthTime(t *testing.T, auther auth.JSONAuth, username, password stri
|
||||
|
||||
// TestJSONAuth_NoTimingAttack tests that authentication timing does not leak user existence
|
||||
func TestJSONAuth_NoTimingAttack(t *testing.T) {
|
||||
setupTestUsers(t)
|
||||
storage := setupTestUsers(t)
|
||||
auther := auth.JSONAuth{}
|
||||
|
||||
// Test cases: mix of valid and invalid usernames
|
||||
testCases := []struct {
|
||||
username string
|
||||
isValid bool
|
||||
@@ -227,7 +215,7 @@ func TestJSONAuth_NoTimingAttack(t *testing.T) {
|
||||
for i := 0; i < samplesPerCase; i++ {
|
||||
// Randomize order to avoid cache effects
|
||||
for _, tc := range testCases {
|
||||
elapsed := measureAuthTime(t, auther, tc.username, "wrongpassword123")
|
||||
elapsed := measureAuthTime(t, auther, storage, tc.username, "wrongpassword123")
|
||||
measurements[tc.username] = append(measurements[tc.username], elapsed)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -52,12 +47,14 @@ func VerifyExternalJWT(tokenString string, secret string, algorithm string, user
|
||||
if expectedMethod == nil {
|
||||
return nil, fmt.Errorf("unsupported signing algorithm: %s", algorithm)
|
||||
}
|
||||
|
||||
|
||||
if token.Method.Alg() != expectedMethod.Alg() {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return jwtVerificationKey(secret, token.Method, algorithm)
|
||||
|
||||
// Return the secret key for HMAC algorithms
|
||||
// For RSA/ECDSA, this would need to be a public key
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -103,102 +100,10 @@ func VerifyExternalJWT(tokenString string, secret string, algorithm string, user
|
||||
return username, claimsMap, nil
|
||||
}
|
||||
|
||||
func jwtVerificationKey(secret string, method jwt.SigningMethod, algorithm string) (interface{}, error) {
|
||||
switch method.(type) {
|
||||
case *jwt.SigningMethodHMAC:
|
||||
return []byte(secret), nil
|
||||
case *jwt.SigningMethodRSA:
|
||||
key, err := parsePublicKeyFromPEM(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("JWT secret must contain an RSA public key in PEM format for algorithm %s", algorithm)
|
||||
}
|
||||
return rsaKey, nil
|
||||
case *jwt.SigningMethodECDSA:
|
||||
key, err := parsePublicKeyFromPEM(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ecKey, ok := key.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("JWT secret must contain an ECDSA public key in PEM format for algorithm %s", algorithm)
|
||||
}
|
||||
if err := ecdsaCurveMatchesAlgorithm(algorithm, ecKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ecKey, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported JWT signing method: %s", method.Alg())
|
||||
}
|
||||
}
|
||||
|
||||
func ecdsaCurveMatchesAlgorithm(algorithm string, pub *ecdsa.PublicKey) error {
|
||||
var want elliptic.Curve
|
||||
switch algorithm {
|
||||
case "ES256":
|
||||
want = elliptic.P256()
|
||||
case "ES384":
|
||||
want = elliptic.P384()
|
||||
case "ES512":
|
||||
want = elliptic.P521()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if pub.Curve != want {
|
||||
return fmt.Errorf("ECDSA public key curve does not match algorithm %s", algorithm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePublicKeyFromPEM(pemData string) (interface{}, error) {
|
||||
rest := []byte(pemData)
|
||||
var lastErr error
|
||||
for len(rest) > 0 {
|
||||
block, rem := pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
rest = rem
|
||||
|
||||
var key interface{}
|
||||
var err error
|
||||
switch block.Type {
|
||||
case "CERTIFICATE":
|
||||
var cert *x509.Certificate
|
||||
cert, err = x509.ParseCertificate(block.Bytes)
|
||||
if err == nil {
|
||||
key = cert.PublicKey
|
||||
}
|
||||
case "PUBLIC KEY":
|
||||
key, err = x509.ParsePKIXPublicKey(block.Bytes)
|
||||
case "RSA PUBLIC KEY":
|
||||
key, err = x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
case "EC PUBLIC KEY":
|
||||
key, err = x509.ParsePKIXPublicKey(block.Bytes)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if key != nil {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT public key from PEM: %w", lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("no public key found in JWT secret (expected PEM: CERTIFICATE, PUBLIC KEY, RSA PUBLIC KEY, or EC PUBLIC KEY)")
|
||||
}
|
||||
|
||||
// ExtractGroupsFromClaims extracts groups from JWT claims based on the configured groups claim field
|
||||
func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField string) []string {
|
||||
var groups []string
|
||||
|
||||
|
||||
if groupsVal, ok := claims[groupsClaimField]; ok {
|
||||
switch v := groupsVal.(type) {
|
||||
case []interface{}:
|
||||
@@ -224,6 +129,6 @@ func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField str
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return groups
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
)
|
||||
|
||||
func TestVerifyExternalJWT_ValidToken(t *testing.T) {
|
||||
@@ -152,140 +144,6 @@ func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ES256_PublicKeyPEM(t *testing.T) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
|
||||
username := "ec-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "ES256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_RS256_PublicKeyPEM(t *testing.T) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
||||
|
||||
username := "rsa-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "RS256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_ES256_WrongCurveRejected(t *testing.T) {
|
||||
p256Priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
p384Priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
verifyDER, err := x509.MarshalPKIXPublicKey(&p384Priv.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKIXPublicKey: %v", err)
|
||||
}
|
||||
verifyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: verifyDER}))
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": "u",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(p256Priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
_, _, err = auth.VerifyExternalJWT(tokenString, verifyPEM, "ES256", "sub")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PEM public key curve does not match algorithm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExternalJWT_CertificatePEM(t *testing.T) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "jwt-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
||||
|
||||
username := "cert-user"
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
tokenString, err := token.SignedString(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString: %v", err)
|
||||
}
|
||||
|
||||
got, _, err := auth.VerifyExternalJWT(tokenString, certPEM, "ES256", "sub")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyExternalJWT: %v", err)
|
||||
}
|
||||
if got != username {
|
||||
t.Errorf("username: got %q want %q", got, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractGroupsFromClaims_ArrayOfStrings(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
"groups": []string{"admin", "users"},
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+268
-96
@@ -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 {
|
||||
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
|
||||
}
|
||||
user.TOTPSecret = ""
|
||||
user.TOTPNonce = ""
|
||||
user.Password = password
|
||||
user.TOTPSecret = "" // reset TOTP secret if it exists
|
||||
user.TOTPNonce = "" // reset TOTP nonce if it exists
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
if asAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
// Ensure version is set for existing users being updated
|
||||
if user.Version == 0 {
|
||||
user.Version = users.CurrentUserMigrationVersion
|
||||
user.Version = 1
|
||||
}
|
||||
err = state.UpdateUser(&user, password, "password", "totpSecret", "totpNonce", "loginMethod", "permissions", "version")
|
||||
err = store.Users.Save(user, true, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not update user: %v", err)
|
||||
}
|
||||
@@ -134,27 +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.Permissions.Admin {
|
||||
fmt.Printf("user %s is already an admin\n", username)
|
||||
return nil
|
||||
}
|
||||
user.Permissions.Admin = true
|
||||
if user.Version == 0 {
|
||||
user.Version = users.CurrentUserMigrationVersion
|
||||
}
|
||||
err = state.UpdateUser(&user, "", "permissions")
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not promote user: %v", err)
|
||||
}
|
||||
fmt.Printf("successfully promoted user to admin: %s\n", username)
|
||||
return nil
|
||||
// UserDefaults defines default settings for new users.
|
||||
type UserDefaults struct {
|
||||
Permissions users.Permissions `yaml:"permissions"`
|
||||
}
|
||||
|
||||
// Frontend defines settings related to the web interface.
|
||||
type Frontend struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
}
|
||||
|
||||
// Source defines a directory to be served.
|
||||
type Source struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
// Server defines server-specific configurations.
|
||||
type Server struct {
|
||||
Port int `yaml:"port"`
|
||||
Database string `yaml:"database"`
|
||||
Sources []Source `yaml:"sources"`
|
||||
Logging []LogConfig `json:"logging"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Levels string `json:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
|
||||
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
|
||||
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
|
||||
NoColors bool `json:"noColors"` // disable colors in the output
|
||||
Utc bool `json:"utc"` // use UTC time in the output instead of local time
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
AdminUsername string `yaml:"adminUsername"`
|
||||
AdminPassword string `yaml:"adminPassword"`
|
||||
}
|
||||
|
||||
// Settings is the top-level configuration structure.
|
||||
type Settings struct {
|
||||
Server Server `yaml:"server"`
|
||||
Frontend Frontend `yaml:"frontend,omitempty"`
|
||||
Auth Auth `yaml:"auth"`
|
||||
UserDefaults UserDefaults `yaml:"userDefaults"`
|
||||
}
|
||||
|
||||
// askQuestion displays a prompt and reads a line of input from the user.
|
||||
// It returns the defaultValue if the user's input is empty.
|
||||
func askQuestion(reader *bufio.Reader, prompt string, defaultValue string) string {
|
||||
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
|
||||
input, _ := reader.ReadString('\n')
|
||||
@@ -165,6 +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)
|
||||
@@ -179,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)
|
||||
}
|
||||
@@ -238,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() {
|
||||
@@ -298,6 +469,7 @@ func generateYaml() {
|
||||
if generateConfig {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func SplitByMultiple(str string) []string {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func freshCLI() cliRoot {
|
||||
return cliRoot{}
|
||||
}
|
||||
|
||||
func newCLIParser(t *testing.T, cli *cliRoot) *kong.Kong {
|
||||
t.Helper()
|
||||
parser, err := kong.New(cli, kong.Name("filebrowser"), kong.Bind(&cli.Globals))
|
||||
require.NoError(t, err)
|
||||
return parser
|
||||
}
|
||||
|
||||
func TestParseDefaultServerCommand(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "run", ctx.Command())
|
||||
}
|
||||
|
||||
func TestParseVersionCommand(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"version"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "version", ctx.Command())
|
||||
}
|
||||
|
||||
func TestParseGlobalConfigFlag(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
_, err := parser.Parse([]string{"-c", "/tmp/custom.yaml", "version"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/tmp/custom.yaml", cli.Config)
|
||||
}
|
||||
|
||||
func TestParseUserSetWithPasswordFlag(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"user", "set", "alice", "--password", "secret", "--admin"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user set <username>", ctx.Command())
|
||||
assert.Equal(t, "alice", cli.User.Set.Username)
|
||||
assert.True(t, cli.User.Set.Password.Provided)
|
||||
assert.Equal(t, "secret", cli.User.Set.Password.Inline)
|
||||
assert.True(t, cli.User.Set.Admin)
|
||||
}
|
||||
|
||||
func TestParseUserSetConfigFlagOrder(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
_, err := parser.Parse([]string{"-c", "other.yaml", "user", "set", "bob", "--password", "x"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "other.yaml", cli.Config)
|
||||
}
|
||||
|
||||
func TestParseSetRuleFlags(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{
|
||||
"set", "rule",
|
||||
"-s", "access", "-p", "/", "-r", "user", "-v", "admin", "--allow",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "set rule", ctx.Command())
|
||||
assert.Equal(t, "access", cli.Set.Rule.Source)
|
||||
assert.Equal(t, "/", cli.Set.Rule.Path)
|
||||
assert.Equal(t, "user", cli.Set.Rule.Role)
|
||||
assert.Equal(t, "admin", cli.Set.Rule.Value)
|
||||
assert.True(t, cli.Set.Rule.Allow)
|
||||
}
|
||||
|
||||
func TestParseLegacySetUser(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"set", "-u", "alice,secret", "-a"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "set", ctx.Command())
|
||||
assert.Equal(t, "alice,secret", cli.Set.User)
|
||||
assert.True(t, cli.Set.Admin)
|
||||
}
|
||||
|
||||
func TestPasswordFlagResolveInline(t *testing.T) {
|
||||
p := passwordFlag{Provided: true, Inline: "inline-pass"}
|
||||
got, err := p.resolve(true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "inline-pass", got)
|
||||
}
|
||||
|
||||
func TestPasswordFlagResolveStdin(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
require.NoError(t, err)
|
||||
oldStdin := os.Stdin
|
||||
os.Stdin = r
|
||||
t.Cleanup(func() { os.Stdin = oldStdin })
|
||||
|
||||
_, err = w.WriteString("piped-secret\n")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
p := passwordFlag{Provided: true}
|
||||
got, err := p.resolve(false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "piped-secret", got)
|
||||
}
|
||||
|
||||
func TestPasswordFlagMissing(t *testing.T) {
|
||||
p := passwordFlag{}
|
||||
_, err := p.resolve(false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--password is required")
|
||||
}
|
||||
|
||||
func TestPasswordFlagNoInputRequiresInline(t *testing.T) {
|
||||
p := passwordFlag{Provided: true}
|
||||
_, err := p.resolve(true)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--no-input")
|
||||
}
|
||||
|
||||
func TestSplitByMultiple(t *testing.T) {
|
||||
assert.Equal(t, []string{"info", "warning", "error"}, SplitByMultiple("info|warning|error"))
|
||||
}
|
||||
|
||||
func TestSetCmdRunSkipsWhenRuleSelected(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"set", "rule", "-s", "x", "-p", "/", "-r", "all"})
|
||||
require.NoError(t, err)
|
||||
cli.Set.User = "should-not-run"
|
||||
err = cli.Set.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSetCmdRunLegacyUser(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"set", "-u", "bad"})
|
||||
require.NoError(t, err)
|
||||
err = cli.Set.Run(ctx)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not enough info")
|
||||
}
|
||||
|
||||
func TestPasswordReadAllFromReader(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(" trimmed \n")
|
||||
data, err := io.ReadAll(&buf)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "trimmed", strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
func TestResolveConfigPathEnvMissingFile(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
|
||||
t.Setenv("FILEBROWSER_CONFIG", missing)
|
||||
config := ""
|
||||
resolveConfigPath(&config)
|
||||
assert.Equal(t, missing, config)
|
||||
_, err := os.Stat(config)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSetupNoInputRejected(t *testing.T) {
|
||||
cli := freshCLI()
|
||||
parser := newCLIParser(t, &cli)
|
||||
ctx, err := parser.Parse([]string{"setup", "--no-input"})
|
||||
require.NoError(t, err)
|
||||
err = ctx.Run()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "interactive input")
|
||||
}
|
||||
@@ -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,227 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Globals holds flags shared across all commands (order-independent).
|
||||
type Globals struct {
|
||||
Config string `short:"c" name:"config" default:"config.yaml" env:"FILEBROWSER_CONFIG" help:"Path to config file"`
|
||||
NoInput bool `name:"no-input" help:"Disable interactive prompts (fail if input required)"`
|
||||
}
|
||||
|
||||
type runCmd struct{}
|
||||
|
||||
type versionCmd struct{}
|
||||
|
||||
type setupCmd struct{}
|
||||
|
||||
type SetCmd struct {
|
||||
User string `short:"u" help:"Deprecated: comma-separated username,password. Use 'user set' instead."`
|
||||
Admin bool `short:"a" help:"Create user as admin (used with -u)"`
|
||||
Rule SetRuleCmd `cmd:"" name:"rule" help:"Add or update an access rule"`
|
||||
}
|
||||
|
||||
type SetRuleCmd struct {
|
||||
Source string `short:"s" name:"source" aliases:"f,sourceName" required:"" help:"Source name from config"`
|
||||
Path string `short:"p" name:"path" aliases:"sourcePath" required:"" help:"Index path within the source"`
|
||||
Role string `short:"r" name:"role" enum:"user,group,all" required:"" help:"Rule target type"`
|
||||
Value string `short:"v" name:"value" help:"Username or group name (required for user/group)"`
|
||||
Allow bool `name:"allow" help:"Allow access (default: deny)"`
|
||||
}
|
||||
|
||||
type UserCmd struct {
|
||||
Set UserSetCmd `cmd:"" name:"set" help:"Create or update a password-authenticated user"`
|
||||
Promote UserPromoteCmd `cmd:"" name:"promote" help:"Grant admin permissions without changing password"`
|
||||
}
|
||||
|
||||
type UserPromoteCmd struct {
|
||||
Username string `arg:"" help:"Username to promote"`
|
||||
}
|
||||
|
||||
type UserSetCmd struct {
|
||||
Username string `arg:"" help:"Username"`
|
||||
Password passwordFlag `name:"password" help:"Password; omit value to prompt (TTY) or read stdin (pipe)"`
|
||||
Admin bool `short:"a" name:"admin" help:"Grant admin permissions"`
|
||||
}
|
||||
|
||||
// passwordFlag accepts --password VALUE (inline) or --password alone (prompt/pipe).
|
||||
type passwordFlag struct {
|
||||
Provided bool
|
||||
Inline string
|
||||
}
|
||||
|
||||
func (p *passwordFlag) Decode(ctx *kong.DecodeContext) error {
|
||||
p.Provided = true
|
||||
if ctx.Scan.Peek().IsValue() {
|
||||
return ctx.Scan.PopValueInto("password", &p.Inline)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p passwordFlag) resolve(noInput bool) (string, error) {
|
||||
if !p.Provided {
|
||||
return "", fmt.Errorf("--password is required (inline value, interactive prompt, or piped stdin)")
|
||||
}
|
||||
if p.Inline != "" {
|
||||
return p.Inline, nil
|
||||
}
|
||||
if noInput {
|
||||
return "", fmt.Errorf("--password requires an inline value or piped stdin when --no-input is set")
|
||||
}
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return promptPassword()
|
||||
}
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read password from stdin: %w", err)
|
||||
}
|
||||
password := strings.TrimSpace(string(data))
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("password must not be empty")
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
func promptPassword() (string, error) {
|
||||
if _, err := fmt.Fprint(os.Stderr, "Password: "); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytePassword, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
password := string(bytePassword)
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("password must not be empty")
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
var rootCLI cliRoot
|
||||
|
||||
type cliRoot struct {
|
||||
Globals `embed:""`
|
||||
|
||||
Run runCmd `cmd:"" default:"1" hidden:"" help:"Start the FileBrowser server"`
|
||||
Version versionCmd `cmd:"" name:"version" help:"Print version information"`
|
||||
Setup setupCmd `cmd:"" name:"setup" help:"Interactive configuration setup"`
|
||||
Set SetCmd `cmd:"" name:"set" help:"Set configuration values (deprecated: use 'user set' for users)"`
|
||||
User UserCmd `cmd:"" name:"user" help:"User management"`
|
||||
}
|
||||
|
||||
func (versionCmd) Run() error {
|
||||
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
|
||||
Version : %v
|
||||
Commit : %v
|
||||
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
|
||||
`, version.Version, version.CommitSHA, version.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (setupCmd) Run(globals *Globals) error {
|
||||
return createConfig(globals.Config, globals.NoInput)
|
||||
}
|
||||
|
||||
func (s *SetCmd) Run(ctx *kong.Context) error {
|
||||
if strings.HasPrefix(ctx.Command(), "set rule") {
|
||||
return nil
|
||||
}
|
||||
if s.User == "" {
|
||||
return fmt.Errorf("missing subcommand for 'set'. Use 'set rule' or 'user set'")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "warning: 'set -u' is deprecated; use 'user set' instead")
|
||||
userInfo := strings.SplitN(s.User, ",", 2)
|
||||
if len(userInfo) < 2 || userInfo[0] == "" || userInfo[1] == "" {
|
||||
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", strings.Split(s.User, ","))
|
||||
}
|
||||
return setUser(userInfo[0], userInfo[1], s.Admin)
|
||||
}
|
||||
|
||||
func (r *SetRuleCmd) Run() error {
|
||||
sourceInfo, ok := settings.Config.Server.NameToSource[r.Source]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid source name: %s", r.Source)
|
||||
}
|
||||
return setRule(sourceInfo.Path, r.Path, r.Role, r.Value, r.Allow)
|
||||
}
|
||||
|
||||
func (u *UserSetCmd) Run(globals *Globals) error {
|
||||
password, err := u.Password.resolve(globals.NoInput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return setUser(u.Username, password, u.Admin)
|
||||
}
|
||||
|
||||
func (p *UserPromoteCmd) Run() error {
|
||||
return promoteUser(p.Username)
|
||||
}
|
||||
|
||||
func resolveConfigPath(config *string) {
|
||||
envConfig := os.Getenv("FILEBROWSER_CONFIG")
|
||||
if *config == "" {
|
||||
if envConfig != "" {
|
||||
*config = envConfig
|
||||
} else {
|
||||
*config = "config.yaml"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireExistingConfig(config string) {
|
||||
envConfig := os.Getenv("FILEBROWSER_CONFIG")
|
||||
if envConfig != "" && config == envConfig {
|
||||
if _, err := os.Stat(config); err != nil {
|
||||
logger.Fatalf("config file %v does not exist, please create it or set the FILEBROWSER_CONFIG environment variable to a valid config file path", config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runCLI() (keepGoing bool, dbExists bool) {
|
||||
generateYaml()
|
||||
|
||||
parser, err := kong.New(&rootCLI,
|
||||
kong.Name("filebrowser"),
|
||||
kong.Description("FileBrowser Quantum - A modern web-based file manager"),
|
||||
kong.Bind(&rootCLI.Globals),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to configure CLI: %v", err)
|
||||
}
|
||||
|
||||
ctx, err := parser.Parse(os.Args[1:])
|
||||
parser.FatalIfErrorf(err)
|
||||
|
||||
resolveConfigPath(&rootCLI.Config)
|
||||
configPath = rootCLI.Config
|
||||
|
||||
cmd := ctx.Command()
|
||||
switch {
|
||||
case cmd == "" || cmd == "run":
|
||||
requireExistingConfig(configPath)
|
||||
dbExists = initializeDatabase(configPath)
|
||||
return true, dbExists
|
||||
case cmd == "version" || cmd == "setup":
|
||||
parser.FatalIfErrorf(ctx.Run(&rootCLI))
|
||||
return false, false
|
||||
case cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
|
||||
requireExistingConfig(configPath)
|
||||
dbExists = initializeDatabase(configPath)
|
||||
parser.FatalIfErrorf(ctx.Run(&rootCLI))
|
||||
return false, dbExists
|
||||
default:
|
||||
parser.Fatalf("unexpected command: %q", cmd)
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
+54
-57
@@ -11,59 +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) bool {
|
||||
func getStore(configFile string) bool {
|
||||
// Use the config file (global flag)
|
||||
settings.Initialize(configFile)
|
||||
|
||||
if err := validateDatabasePaths(); err != nil {
|
||||
logger.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
if checkMigrationNeeded() {
|
||||
logger.Info("Old database detected, starting migration...")
|
||||
err := migrateFromBoltToSQLite()
|
||||
if err != nil {
|
||||
logger.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
store, existingDb, err := state.Open(settings.Config.Server.DatabaseV2.Path)
|
||||
s, hasDB, err := storage.InitializeDb(settings.Config.Server.Database)
|
||||
if err != nil {
|
||||
logger.Fatalf("could not initialize state: %v", err)
|
||||
logger.Fatalf("could not load db info: %v", err)
|
||||
}
|
||||
runtimeApp, err = app.WireServices(store)
|
||||
if err != nil {
|
||||
logger.Fatalf("could not wire services: %v", err)
|
||||
}
|
||||
return existingDb
|
||||
store = s
|
||||
return hasDB
|
||||
}
|
||||
|
||||
func generalUsage() {
|
||||
fmt.Printf(`usage: ./filebrowser <command> [options]
|
||||
commands:
|
||||
-h Print help
|
||||
-c Print the default config file
|
||||
version Print version information
|
||||
set -u Username and password for the new user
|
||||
set -a Create user as admin
|
||||
set -s Specify a user scope
|
||||
set -h Print this help message
|
||||
`)
|
||||
}
|
||||
|
||||
func StartFilebrowser() {
|
||||
keepGoing, dbExists := runCLI()
|
||||
keepGoing := runCLI()
|
||||
if !keepGoing {
|
||||
return
|
||||
}
|
||||
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.DatabaseV2.Path)
|
||||
if !dbExists {
|
||||
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.DatabaseV2.Path)
|
||||
}
|
||||
if !settings.Config.Server.DisableUpdateCheck {
|
||||
info, _ := utils.CheckForUpdates()
|
||||
if info.LatestVersion != "" {
|
||||
@@ -82,10 +76,15 @@ func StartFilebrowser() {
|
||||
|
||||
done := make(chan struct{}) // Signals server has stopped
|
||||
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
|
||||
dbExists := getStore(configPath)
|
||||
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
|
||||
if !dbExists {
|
||||
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
|
||||
}
|
||||
|
||||
// Dev mode enables development features like template hot-reloading
|
||||
_, err := os.Stat("internal/web/dist")
|
||||
// In dev mode, always use filesystem assets. Otherwise, check if internal/web/dist exists
|
||||
_, 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)
|
||||
}
|
||||
@@ -116,7 +115,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 {
|
||||
@@ -129,24 +128,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
|
||||
@@ -176,22 +177,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)
|
||||
@@ -207,6 +206,8 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
|
||||
logger.Debugf("Exiftool Enabled : %v", settings.Config.Integrations.Media.ExiftoolPath != "")
|
||||
|
||||
// Generate PWA icons after preview service is initialized
|
||||
if err := icons.GeneratePWAIcons(); err != nil {
|
||||
@@ -216,10 +217,6 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
|
||||
// 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
|
||||
}
|
||||
|
||||
+112
-145
@@ -6,11 +6,9 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -18,135 +16,100 @@ var createBackup = false
|
||||
|
||||
func validateUserInfo(newDB bool) {
|
||||
// update source info for users if names/sources/paths might have changed
|
||||
usersList, err := state.GetAllUsers()
|
||||
usersList, err := store.Users.Gets()
|
||||
if err != nil {
|
||||
logger.Fatalf("could not load users: %v", err)
|
||||
}
|
||||
for i := range usersList {
|
||||
user := &usersList[i]
|
||||
changedFields := make([]string, 0, 8)
|
||||
for _, user := range usersList {
|
||||
changePass := false
|
||||
|
||||
updateUser := false
|
||||
if updateUserScopes(user) {
|
||||
changedFields = append(changedFields, "backendScopes")
|
||||
updateUser = true
|
||||
}
|
||||
if updatePermissions(user) {
|
||||
changedFields = append(changedFields, "permissions", "perm", "version")
|
||||
}
|
||||
if updateSourcePermissions(user) {
|
||||
changedFields = append(changedFields, "backendScopes", "backendSourcePermissions", "version")
|
||||
updateUser = true
|
||||
}
|
||||
if updatePreviewSettings(user) {
|
||||
changedFields = append(changedFields, "preview")
|
||||
updateUser = true
|
||||
}
|
||||
if updateLoginType(user) {
|
||||
changedFields = append(changedFields, "loginMethod")
|
||||
updateUser = true
|
||||
}
|
||||
if updateShowFirstLogin(user) {
|
||||
changedFields = append(changedFields, "showFirstLogin")
|
||||
updateUser = true
|
||||
}
|
||||
if updateSidebarLinks(user) {
|
||||
changedFields = append(changedFields, "sidebarLinks")
|
||||
updateUser = true
|
||||
}
|
||||
if updateTokens(user) {
|
||||
changedFields = append(changedFields, "tokens", "version")
|
||||
}
|
||||
if normalizeApiTokenPermissions(user) {
|
||||
changedFields = append(changedFields, "tokens")
|
||||
}
|
||||
if state.ApplyEnforcedSyncToUser(user) {
|
||||
changedFields = append(changedFields, settings.UserJSONFieldsForEnforcedSync()...)
|
||||
}
|
||||
if state.ApplyEnforcedSourcePermissionsSyncToUser(user) {
|
||||
changedFields = append(changedFields, "backendScopes", "backendSourcePermissions")
|
||||
}
|
||||
if user.Version < users.ProfileStorageVersion {
|
||||
user.Version = users.ProfileStorageVersion
|
||||
changedFields = append(changedFields, "version")
|
||||
updateUser = true
|
||||
}
|
||||
adminUser := settings.Config.Auth.AdminUsername
|
||||
if adminUser == "" {
|
||||
adminUser = "admin"
|
||||
}
|
||||
if user.Username == adminUser && user.Permissions.Admin {
|
||||
adminPerms := settings.AdminPerms()
|
||||
if user.Permissions.Share != adminPerms.Share || user.Permissions.Api != adminPerms.Api {
|
||||
user.Permissions.Share = adminPerms.Share
|
||||
user.Permissions.Api = adminPerms.Api
|
||||
user.Permissions.Admin = true
|
||||
changedFields = append(changedFields, "permissions")
|
||||
}
|
||||
}
|
||||
if user.Username == adminUser && settings.Config.Auth.AdminPassword != "" && user.LoginMethod == users.LoginMethodPassword {
|
||||
adminPass := settings.Config.Auth.AdminPassword
|
||||
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
|
||||
if user.Username == adminUser && adminPass != "" && passwordEnabled {
|
||||
logger.Info("Resetting admin user to default username and password.")
|
||||
user.Permissions = settings.AdminPerms()
|
||||
user.Permissions.Admin = true
|
||||
user.Password = settings.Config.Auth.AdminPassword
|
||||
changedFields = append(changedFields, "permissions", "password")
|
||||
updateUser = true
|
||||
changePass = true
|
||||
}
|
||||
|
||||
changedFields = dedupeFields(changedFields)
|
||||
if len(changedFields) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
skipCreateBackup := os.Getenv("FILEBROWSER_DISABLE_AUTOMATIC_BACKUP") == "true" || newDB
|
||||
if createBackup && !skipCreateBackup {
|
||||
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
|
||||
err = fileutils.CopyFile(settings.Config.Server.DatabaseV2.Path, fmt.Sprintf("%s.bak", settings.Config.Server.DatabaseV2.Path))
|
||||
if updateUser {
|
||||
skipCreateBackup := os.Getenv("FILEBROWSER_DISABLE_AUTOMATIC_BACKUP") == "true" || newDB
|
||||
if createBackup && !skipCreateBackup {
|
||||
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
|
||||
err = fileutils.CopyFile(settings.Config.Server.Database, fmt.Sprintf("%s.bak", settings.Config.Server.Database))
|
||||
if err != nil {
|
||||
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
|
||||
}
|
||||
}
|
||||
err := store.Users.Save(user, changePass, true)
|
||||
if err != nil {
|
||||
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
}
|
||||
}
|
||||
plainPass := ""
|
||||
if changePass {
|
||||
plainPass = user.Password
|
||||
}
|
||||
if err := state.UpdateUser(user, plainPass, changedFields...); err != nil {
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dedupeFields(fields []string) []string {
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(field)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, field)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func updateUserScopes(user *users.User) bool {
|
||||
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
|
||||
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
|
||||
user.BackendScopes = newScopes
|
||||
return changed
|
||||
}
|
||||
newScopes := []users.SourceScope{}
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
func updateSourcePermissions(user *users.User) bool {
|
||||
changed := false
|
||||
if user.Version < users.SourcePermissionsMigrationVersion {
|
||||
if users.MigrateToSourcePermissions(user) {
|
||||
changed = true
|
||||
// Build map for existing scopes by Name
|
||||
existing := make(map[string]users.SourceScope)
|
||||
for _, s := range user.Scopes {
|
||||
existing[s.Name] = s
|
||||
}
|
||||
|
||||
// Preserve order by using Config.Server.Sources
|
||||
for _, src := range settings.Config.Server.Sources {
|
||||
existingScope, ok := existing[src.Path]
|
||||
if ok {
|
||||
// If scope is empty and there's a default, apply default
|
||||
if existingScope.Scope == "" {
|
||||
existingScope.Scope = src.Config.DefaultUserScope
|
||||
}
|
||||
} else if src.Config.DefaultEnabled {
|
||||
existingScope.Scope = src.Config.DefaultUserScope
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: src.Path,
|
||||
Scope: existingScope.Scope,
|
||||
})
|
||||
seen[src.Path] = struct{}{}
|
||||
}
|
||||
|
||||
// Preserve user-defined scopes not matching current sources, append to end
|
||||
for _, s := range user.Scopes {
|
||||
if _, ok := seen[s.Name]; !ok {
|
||||
newScopes = append(newScopes, s)
|
||||
}
|
||||
}
|
||||
if users.EnsureSourcePermissionsForScopes(
|
||||
user,
|
||||
settings.DefaultSourceFilePermissions(),
|
||||
settings.AdminSourceFilePermissions(),
|
||||
) {
|
||||
changed = true
|
||||
}
|
||||
changed := !reflect.DeepEqual(user.Scopes, newScopes)
|
||||
user.Scopes = newScopes
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -231,37 +194,61 @@ func updatePreviewSettings(user *users.User) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
|
||||
// updateSidebarLinks checks if user has stale source links and rebuilds them if needed
|
||||
func updateSidebarLinks(user *users.User) bool {
|
||||
updated := false
|
||||
// Count source links and check if any are still valid
|
||||
sourceLinksCount := 0
|
||||
validSourceLinksCount := 0
|
||||
|
||||
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
|
||||
user.SidebarLinks = normalized
|
||||
updated = true
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
sourceLinksCount++
|
||||
// Check if this source still exists
|
||||
if link.SourceName != "" {
|
||||
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
|
||||
validSourceLinksCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes) {
|
||||
return updated
|
||||
// If user has no source links, don't update anything
|
||||
if sourceLinksCount == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 && len(user.SidebarLinks) > 0 {
|
||||
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
|
||||
} else if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 {
|
||||
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
|
||||
} else {
|
||||
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
|
||||
// If user has source links but NONE are valid, rebuild from their scopes
|
||||
if validSourceLinksCount == 0 {
|
||||
logger.Infof("User %s has %d stale source links, rebuilding from scopes", user.Username, sourceLinksCount)
|
||||
|
||||
// Remove all existing source links
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range user.SidebarLinks {
|
||||
if !strings.HasPrefix(link.Category, "source") {
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
merged, changed := usersidebar.EnsureSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
|
||||
if changed {
|
||||
user.SidebarLinks = merged
|
||||
updated = true
|
||||
}
|
||||
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
|
||||
user.SidebarLinks = normalized
|
||||
updated = true
|
||||
}
|
||||
return updated
|
||||
// User has at least one valid source link, no update needed
|
||||
return false
|
||||
}
|
||||
|
||||
func updateTokens(user *users.User) bool {
|
||||
@@ -272,29 +259,9 @@ func updateTokens(user *users.User) bool {
|
||||
user.Tokens = make(map[string]users.AuthToken)
|
||||
for name, token := range user.ApiKeys {
|
||||
token.Token = token.Key
|
||||
token.Name = name
|
||||
users.StoreToken(user.Tokens, token)
|
||||
user.Tokens[name] = token
|
||||
}
|
||||
}
|
||||
user.Version = 2
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeApiTokenPermissions(user *users.User) bool {
|
||||
if user == nil || len(user.Tokens) == 0 {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for name, token := range user.Tokens {
|
||||
if token.Name == "" || name != token.Name {
|
||||
continue
|
||||
}
|
||||
sanitized := users.SanitizeTokenPermissions(token.Permissions)
|
||||
if sanitized != token.Permissions {
|
||||
token.Permissions = sanitized
|
||||
user.Tokens[name] = token
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestUpdateUserScopes_mergesMissingDefaultEnabled(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: "graham"},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: fixturePlaywrightSource, Scope: "/myfolder"},
|
||||
{Path: fixtureDockerSource, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateUserScopes(user) {
|
||||
t.Fatal("expected missing defaultEnabled access source to be merged")
|
||||
}
|
||||
if len(user.BackendScopes) != 3 {
|
||||
t.Fatalf("scopes=%#v want 3 including access", user.BackendScopes)
|
||||
}
|
||||
found := false
|
||||
for _, scope := range user.BackendScopes {
|
||||
if scope.Path == fixtureAccessSource {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("access source should be added for defaultEnabled: %#v", user.BackendScopes)
|
||||
}
|
||||
// Preserve existing scope paths
|
||||
for _, scope := range user.BackendScopes {
|
||||
if scope.Path == fixturePlaywrightSource && scope.Scope != "/myfolder" {
|
||||
t.Fatalf("playwright scope path changed: %#v", scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
settings.Env.IsPlaywright = true
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
settings.Config.Server.DatabaseV2.Path = dbPath
|
||||
settings.Config.Server.DatabaseV2.MigrateFrom = settingsMigrationBoltPath(t)
|
||||
if err := migrateFromBoltToSQLite(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sqlStore, _, err := sqldb.NewSQLStoreWithOptions(dbPath, sqldb.NewSQLStoreOpts{SkipQuickSetup: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sqlStore.Close()
|
||||
|
||||
graham, err := sqlStore.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if graham.BackendSourcePermissions == nil {
|
||||
t.Fatal("expected backend source permissions after migration")
|
||||
}
|
||||
// Migration fixture itself may omit access; merge happens in updateUserScopes.
|
||||
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; ok {
|
||||
t.Fatalf("graham should not have access permissions from migration alone: %#v", graham.BackendSourcePermissions)
|
||||
}
|
||||
|
||||
updateUserScopes(graham)
|
||||
updateSourcePermissions(graham)
|
||||
updateSidebarLinks(graham)
|
||||
|
||||
foundScope := false
|
||||
for _, scope := range graham.BackendScopes {
|
||||
if scope.Path == fixtureAccessSource {
|
||||
foundScope = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundScope {
|
||||
t.Fatalf("graham should gain access scope after merge: %#v", graham.BackendScopes)
|
||||
}
|
||||
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; !ok {
|
||||
t.Fatalf("graham should have access source permissions after merge: %#v", graham.BackendSourcePermissions)
|
||||
}
|
||||
|
||||
foundLink := false
|
||||
for _, link := range usersidebar.FrontendLinks(graham.SidebarLinks, true) {
|
||||
if link.Name == "access" || link.SourceName == fixtureAccessSource {
|
||||
foundLink = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundLink {
|
||||
t.Fatalf("graham should have access sidebar link after merge: %#v", graham.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserScopes_seedsDefaultEnabledWhenEmpty(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: "newuser"},
|
||||
}
|
||||
|
||||
if !updateUserScopes(user) {
|
||||
t.Fatal("expected default-enabled scopes to be seeded")
|
||||
}
|
||||
if len(user.BackendScopes) != 3 {
|
||||
t.Fatalf("scopes=%#v want all default-enabled sources", user.BackendScopes)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestUpdateSidebarLinks_dedupesDuplicateMigratedLinks(t *testing.T) {
|
||||
settingsConfig := "../../_docker/src/settings/backend/config.yaml"
|
||||
settings.Initialize(settingsConfig)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
SidebarLinks: []users.SidebarLink{
|
||||
{Name: "playwright + files", Category: "source", SourceName: "/app/frontend/tests/playwright-files", Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: "/app/backend", Target: "/"},
|
||||
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
|
||||
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "access", Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
|
||||
},
|
||||
},
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: "../frontend/tests/playwright-files", Scope: "/"},
|
||||
{Path: ".", Scope: "/"},
|
||||
{Path: "/tests/playwright-files", Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
|
||||
settings.Initialize(settingsMigrationConfigPath(t))
|
||||
alignSettingsSourcesForMigrationFixture(t)
|
||||
|
||||
user := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "admin",
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: fixturePlaywrightSource, Scope: "/"},
|
||||
{Path: fixtureDockerSource, Scope: "/"},
|
||||
{Path: fixtureAccessSource, Scope: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
if !updateSidebarLinks(user) {
|
||||
t.Fatal("expected updateSidebarLinks to return true")
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, link := range user.SidebarLinks {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
|
||||
}
|
||||
}
|
||||
+65
-67
@@ -4,8 +4,8 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -25,7 +25,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
"sourceA": &sourceA,
|
||||
"sourceB": &sourceB,
|
||||
}
|
||||
settings.InitializeUserResolvers()
|
||||
|
||||
// ---------------------
|
||||
// Test Cases Definition
|
||||
@@ -33,110 +32,108 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *users.User
|
||||
expectedPhase1 []users.BackendScope
|
||||
expectedPhase2 []users.BackendScope
|
||||
expectedPhase1 []users.SourceScope
|
||||
expectedPhase2 []users.SourceScope
|
||||
}{
|
||||
{
|
||||
name: "Single valid scope",
|
||||
user: &users.User{
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Scope: "/home", Path: "/pathA"},
|
||||
Scopes: []users.SourceScope{
|
||||
{Scope: "/home", Name: "/pathA"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/home", Path: "/pathA"},
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/home", Name: "/pathA"},
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
// Phase2: both already present after phase1 merge.
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/home", Path: "/pathA"},
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/home", Name: "/pathA"},
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Single empty scope path",
|
||||
user: &users.User{
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Scope: "", Path: "/pathB"},
|
||||
Scopes: []users.SourceScope{
|
||||
{Scope: "", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/defaultA", Path: "/pathA"},
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/defaultA", Name: "/pathA"},
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Two scopes, one includes username in path",
|
||||
user: &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: "user123"},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Scope: "/home/user123", Path: "/pathA"},
|
||||
{Scope: "/data", Path: "/pathB"},
|
||||
Username: "user123",
|
||||
Scopes: []users.SourceScope{
|
||||
{Scope: "/home/user123", Name: "/pathA"},
|
||||
{Scope: "/data", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/home/user123", Path: "/pathA"},
|
||||
{Scope: "/data", Path: "/pathB"},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/home/user123", Name: "/pathA"},
|
||||
{Scope: "/data", Name: "/pathB"},
|
||||
},
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/home/user123", Path: "/pathA"},
|
||||
{Scope: "/data", Path: "/pathB"},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/home/user123", Name: "/pathA"},
|
||||
{Scope: "/data", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Two scopes, one with empty name",
|
||||
user: &users.User{
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Scope: "/home", Path: "/pathB"},
|
||||
{Scope: "/data", Path: "/somethingElse"},
|
||||
Scopes: []users.SourceScope{
|
||||
{Scope: "/home", Name: "/pathB"},
|
||||
{Scope: "/data", Name: "/somethingElse"},
|
||||
},
|
||||
},
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/home", Path: "/pathB"},
|
||||
{Scope: "/data", Path: "/somethingElse"},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/home", Name: "/pathB"},
|
||||
{Scope: "/data", Name: "/somethingElse"},
|
||||
},
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/defaultA", Path: "/pathA"},
|
||||
{Scope: "/home", Path: "/pathB"},
|
||||
{Scope: "/data", Path: "/somethingElse"},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/defaultA", Name: "/pathA"},
|
||||
{Scope: "/home", Name: "/pathB"},
|
||||
{Scope: "/data", Name: "/somethingElse"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "No scopes at all",
|
||||
user: &users.User{
|
||||
BackendScopes: []users.BackendScope{},
|
||||
Scopes: []users.SourceScope{},
|
||||
},
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/defaultA", Path: "/pathA"},
|
||||
{Scope: "/defaultB", Path: "/pathB"},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/defaultA", Name: "/pathA"},
|
||||
{Scope: "/defaultB", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All user Scope and source change",
|
||||
user: &users.User{
|
||||
FrontendUser: users.FrontendUser{Username: "user123"},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Scope: "/defaultC/user123", Path: "/pathC"},
|
||||
{Scope: "/defaultA/user123", Path: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Path: "/pathB"},
|
||||
Username: "user123",
|
||||
Scopes: []users.SourceScope{
|
||||
{Scope: "/defaultC/user123", Name: "/pathC"},
|
||||
{Scope: "/defaultA/user123", Name: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Name: "/pathB"},
|
||||
},
|
||||
},
|
||||
// updateUserScopes emits configured sources first (Sources order), then unknown paths.
|
||||
expectedPhase1: []users.BackendScope{
|
||||
{Scope: "/defaultA/user123", Path: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Path: "/pathB"},
|
||||
{Scope: "/defaultC/user123", Path: "/pathC"},
|
||||
expectedPhase1: []users.SourceScope{
|
||||
{Scope: "/defaultA/user123", Name: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Name: "/pathB"},
|
||||
{Scope: "/defaultC/user123", Name: "/pathC"},
|
||||
},
|
||||
expectedPhase2: []users.BackendScope{
|
||||
{Scope: "/defaultA/user123", Path: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Path: "/pathB"},
|
||||
{Scope: "/defaultC/user123", Path: "/pathC"},
|
||||
expectedPhase2: []users.SourceScope{
|
||||
{Scope: "/defaultA/user123", Name: "/pathA"},
|
||||
{Scope: "/defaultB/user123", Name: "/pathB"},
|
||||
{Scope: "/defaultC/user123", Name: "/pathC"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -146,10 +143,10 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
// ---------------------
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name+"_Phase1", func(t *testing.T) {
|
||||
originalScopes := tc.user.BackendScopes
|
||||
originalScopes := tc.user.Scopes
|
||||
updated := updateUserScopes(tc.user)
|
||||
assert.Equal(t, tc.expectedPhase1, tc.user.BackendScopes, "Phase1 scope mismatch for test case: %s", tc.name)
|
||||
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.BackendScopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.BackendScopes, tc.expectedPhase1)
|
||||
assert.Equal(t, tc.expectedPhase1, tc.user.Scopes, "Phase1 scope mismatch for test case: %s", tc.name)
|
||||
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.Scopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.Scopes, tc.expectedPhase1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,6 +156,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
sourceA = settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
|
||||
DefaultEnabled: true,
|
||||
DefaultUserScope: "/defaultA",
|
||||
CreateUserDir: true,
|
||||
}}
|
||||
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
|
||||
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
|
||||
@@ -174,10 +172,10 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
|
||||
// Run again without resetting user objects to test idempotency + renaming
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name+"_Phase2", func(t *testing.T) {
|
||||
originalScopes := tc.user.BackendScopes
|
||||
originalScopes := tc.user.Scopes
|
||||
updated := updateUserScopes(tc.user)
|
||||
assert.Equal(t, tc.expectedPhase2, tc.user.BackendScopes, "Phase2 scope mismatch for test case: %s", tc.name)
|
||||
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.BackendScopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.BackendScopes, tc.expectedPhase1)
|
||||
assert.Equal(t, tc.expectedPhase2, tc.user.Scopes, "Phase2 scope mismatch for test case: %s", tc.name)
|
||||
assert.Equal(t, updated, !reflect.DeepEqual(originalScopes, tc.user.Scopes), "Phase2 scope change detection failed:\t %s vs expected:\t %s", tc.user.Scopes, tc.expectedPhase1)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,19 +19,11 @@ var (
|
||||
ErrInvalidRequestParams = errors.New("invalid request params")
|
||||
ErrSourceIsParent = errors.New("source is parent")
|
||||
ErrNoTotpProvided = errors.New("OTP code is required for user")
|
||||
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not configured")
|
||||
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
|
||||
ErrUnauthorized = errors.New("user unauthorized")
|
||||
ErrNotIndexed = errors.New("directory or item excluded from indexing")
|
||||
ErrNotViewable = errors.New("directory or item is not viewable")
|
||||
ErrPathEscapesScope = errors.New("resolved path escapes scope")
|
||||
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
|
||||
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
|
||||
ErrUseMediaStream = errors.New("use /media/stream for audio and video")
|
||||
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
|
||||
ErrPasskeyMFARequired = errors.New("passkey MFA is required")
|
||||
ErrPasskeyVerification = errors.New("passkey verification failed")
|
||||
ErrPasskeyNoCredential = errors.New("no passkey credentials found for user")
|
||||
ErrPasskeyExists = errors.New("passkey credential already exists")
|
||||
ErrPasskeyNotEnabled = errors.New("passkey authentication is not enabled")
|
||||
ErrPasskeyInvalidSession = errors.New("invalid or expired passkey session")
|
||||
)
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
ldap "github.com/go-ldap/ldap/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -61,7 +61,6 @@ type LoginMethods struct {
|
||||
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
|
||||
LdapAuth LdapConfig `json:"ldap" validate:"omitempty"`
|
||||
JwtAuth JwtAuthConfig `json:"jwt" validate:"omitempty"`
|
||||
PasskeyAuth PasskeyAuthConfig `json:"passkey" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type PasswordAuthConfig struct {
|
||||
@@ -107,24 +106,15 @@ type LdapConfig struct {
|
||||
}
|
||||
|
||||
// JwtAuthConfig configures external JWT token authentication
|
||||
// Similar to Grafana's JWT auth: verifies HS* tokens with a shared secret, or RS*/ES* with a PEM public key (or certificate)
|
||||
// Similar to Grafana's JWT auth: accepts external JWT tokens signed with a shared secret
|
||||
// The query parameter is hardcoded to "jwt" (e.g. ?jwt=<token>)
|
||||
type JwtAuthConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
|
||||
Secret string `json:"secret"` // secret: Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
|
||||
Secret string `json:"secret"` // secret: shared secret key for verifying JWT token signatures (required)
|
||||
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
|
||||
}
|
||||
|
||||
// PasskeyAuthConfig configures WebAuthn / Passkey authentication as a second factor.
|
||||
type PasskeyAuthConfig struct {
|
||||
Enabled bool `json:"enabled"` // whether to enable passkey MFA
|
||||
RPDisplayName string `json:"rpDisplayName"` // the Relying Party display name (defaults to frontend name)
|
||||
RPID string `json:"rpId"` // the Relying Party ID; if empty, auto-derived from base URL host
|
||||
RPOrigins []string `json:"rpOrigins"` // allowed origins; if empty, auto-derived from base URL
|
||||
LoginButtonText string `json:"loginButtonText"` // custom text for the passkey login button
|
||||
}
|
||||
|
||||
// ValidateLdapAuth checks LDAP config and sets defaults. Call when LDAP is enabled.
|
||||
func ValidateLdapAuth() error {
|
||||
ldapCfg := &Config.Auth.Methods.LdapAuth
|
||||
@@ -149,7 +139,6 @@ func ValidateLdapAuth() error {
|
||||
if ldapCfg.UserFilter == "" {
|
||||
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
|
||||
}
|
||||
applyAuthCommonDefaults(&ldapCfg.AuthCommon)
|
||||
if err := verifyLdapConnection(); err != nil {
|
||||
logger.Fatalf("LDAP connection check failed: %v", err)
|
||||
}
|
||||
@@ -159,7 +148,9 @@ func ValidateLdapAuth() error {
|
||||
// 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
|
||||
applyAuthCommonDefaults(&oidcCfg.AuthCommon)
|
||||
if oidcCfg.GroupsClaim == "" {
|
||||
oidcCfg.GroupsClaim = "groups"
|
||||
}
|
||||
if oidcCfg.UserIdentifier == "" {
|
||||
oidcCfg.UserIdentifier = "preferred_username"
|
||||
}
|
||||
@@ -288,7 +279,9 @@ func ValidateJwtAuth() error {
|
||||
if jwtCfg.Algorithm == "" {
|
||||
jwtCfg.Algorithm = "HS256"
|
||||
}
|
||||
applyAuthCommonDefaults(&jwtCfg.AuthCommon)
|
||||
if jwtCfg.GroupsClaim == "" {
|
||||
jwtCfg.GroupsClaim = "groups"
|
||||
}
|
||||
if jwtCfg.UserIdentifier == "" {
|
||||
jwtCfg.UserIdentifier = "sub"
|
||||
}
|
||||
File renamed without changes.
@@ -2,13 +2,13 @@ package settings
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -16,11 +16,10 @@ import (
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -52,21 +51,23 @@ func Initialize(configFile string) {
|
||||
}
|
||||
setupFs()
|
||||
setupServer()
|
||||
setupHttp()
|
||||
setupAuth(false)
|
||||
setupSources(false)
|
||||
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
|
||||
setupUrls()
|
||||
warnHttpProxyConfig()
|
||||
setupFrontend(false)
|
||||
setupMedia(false)
|
||||
}
|
||||
|
||||
func setupServer() {
|
||||
if Config.Server.ListenAddress == "" {
|
||||
Config.Server.ListenAddress = "0.0.0.0"
|
||||
}
|
||||
// Check environment variable first (overrides config file)
|
||||
if os.Getenv("FILEBROWSER_SQL_WAL") == "true" {
|
||||
Config.Server.IndexSqlConfig.WalMode = true
|
||||
}
|
||||
// WalMode is false by default (OFF journaling)
|
||||
}
|
||||
|
||||
func setupEnv() {
|
||||
@@ -80,42 +81,6 @@ func setupEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
func setupHttp() {
|
||||
if err := ValidateHttpConfig(Config.Http); err != nil {
|
||||
logger.Fatal(err.Error())
|
||||
}
|
||||
if Config.Http.ListenAddress == "" {
|
||||
Config.Http.ListenAddress = "0.0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
func warnHttpProxyConfig() {
|
||||
if Env.IsDevMode || Env.IsPlaywright {
|
||||
return
|
||||
}
|
||||
if Config.Http.BaseURL != "/" && !Config.Http.TrustProxyHeaders {
|
||||
logger.Warning(`http.baseURL is not "/" but http.trustProxyHeaders is false. Behind a reverse proxy on a subpath, set trustProxyHeaders: true so cookies, client IP, OIDC redirects, and URLs resolve correctly. See https://filebrowserquantum.com/en/docs/configuration/http/#trustproxyheaders`)
|
||||
}
|
||||
if !Env.IsDevMode {
|
||||
if u := Config.Http.ExternalUrl; u != "" && strings.HasPrefix(strings.ToLower(u), "http://") {
|
||||
logger.Warning("http.externalUrl uses http when the url should be https for a production environment")
|
||||
}
|
||||
if u := Config.Integrations.OnlyOffice.Url; u != "" && strings.HasPrefix(strings.ToLower(u), "http://") {
|
||||
logger.Warning("integrations.office.url uses http when it should be https for a production environment.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func warnOidcProxyHeaders() {
|
||||
if !Config.Auth.Methods.OidcAuth.Enabled {
|
||||
return
|
||||
}
|
||||
if Config.Http.TrustProxyHeaders {
|
||||
return
|
||||
}
|
||||
logger.Warning("OIDC is enabled but http.trustProxyHeaders is false. Behind a reverse proxy, FileBrowser builds the OIDC redirect_uri from the incoming request; set trustProxyHeaders: true and configure your proxy to send X-Forwarded-Proto and X-Forwarded-Host. http.externalUrl does not apply to OIDC redirects.")
|
||||
}
|
||||
|
||||
func setupFs() {
|
||||
// Convert permission values (like 644, 755) to octal interpretation
|
||||
filePermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateFilePermission, 8, 32)
|
||||
@@ -128,15 +93,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)
|
||||
|
||||
}
|
||||
@@ -355,45 +316,18 @@ func setupMedia(generate bool) {
|
||||
Config.Integrations.Media.Convert.VideoPreview[k] = v
|
||||
}
|
||||
|
||||
if !generate && !isGoTest() {
|
||||
setupFFmpegIntegration()
|
||||
// Resolve exiftool path once at startup: validate user path or discover via PATH
|
||||
if Config.Integrations.Media.ExiftoolPath != "" && !generate {
|
||||
if err := exec.Command(Config.Integrations.Media.ExiftoolPath, "-ver").Run(); err != nil {
|
||||
logger.Warningf("exiftool path is invalid or not executable: %q (%v); disabling exiftool", Config.Integrations.Media.ExiftoolPath, err)
|
||||
Config.Integrations.Media.ExiftoolPath = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isGoTest() bool {
|
||||
return flag.Lookup("test.v") != nil
|
||||
}
|
||||
|
||||
func setupFFmpegIntegration() {
|
||||
maxConcurrent := Config.Server.NumImageProcessors
|
||||
if maxConcurrent < 1 {
|
||||
maxConcurrent = 4
|
||||
if Config.Integrations.Media.ExiftoolPath == "" && !generate {
|
||||
if path, err := exec.LookPath("exiftool"); err == nil && path != "" {
|
||||
Config.Integrations.Media.ExiftoolPath = path
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -440,7 +374,7 @@ func setupSources(generate bool) {
|
||||
}
|
||||
// clean up the in memory source list to be accurate and unique
|
||||
sourceList := []*Source{}
|
||||
defaultScopes := []users.BackendScope{}
|
||||
defaultScopes := []users.SourceScope{}
|
||||
allSourceNames := []string{}
|
||||
if len(Config.Server.Sources) == 1 {
|
||||
Config.Server.Sources[0].Config.DefaultEnabled = true
|
||||
@@ -455,8 +389,8 @@ func setupSources(generate bool) {
|
||||
if ok && !slices.Contains(allSourceNames, source.Name) {
|
||||
sourceList = append(sourceList, source)
|
||||
if source.Config.DefaultEnabled {
|
||||
defaultScopes = append(defaultScopes, users.BackendScope{
|
||||
Path: source.Path,
|
||||
defaultScopes = append(defaultScopes, users.SourceScope{
|
||||
Name: source.Path,
|
||||
Scope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
@@ -467,40 +401,23 @@ func setupSources(generate bool) {
|
||||
}
|
||||
Config.UserDefaults.DefaultScopes = defaultScopes
|
||||
Config.Server.Sources = sourceList
|
||||
finalizeConfigSourceDefaultPermissions()
|
||||
}
|
||||
|
||||
func finalizeConfigSourceDefaultPermissions() {
|
||||
Env.ConfigSourceDefaultPermissions = nil
|
||||
for _, source := range Config.Server.Sources {
|
||||
if source == nil || len(source.Config.DefaultPermissionsFromConfig) == 0 {
|
||||
continue
|
||||
}
|
||||
Env.ConfigSourceDefaultPermissions = source.Config.DefaultPermissionsFromConfig
|
||||
perms := NormalizeSourceFilePermissions(source.Config.DefaultPermissions)
|
||||
for flag, val := range source.Config.DefaultPermissionsFromConfig {
|
||||
setSourcePermissionFlag(&perms, flag, val)
|
||||
}
|
||||
source.Config.DefaultPermissions = users.MarkSourceFilePermissionsConfigured(perms)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func setupUrls() {
|
||||
baseurl := strings.Trim(Config.Http.BaseURL, "/")
|
||||
baseurl := strings.Trim(Config.Server.BaseURL, "/")
|
||||
if baseurl == "" {
|
||||
Config.Http.BaseURL = "/"
|
||||
Config.Server.BaseURL = "/"
|
||||
} else {
|
||||
Config.Http.BaseURL = "/" + baseurl + "/"
|
||||
Config.Server.BaseURL = "/" + baseurl + "/"
|
||||
}
|
||||
if Config.Http.BaseURL != "/" {
|
||||
if Config.Http.ExternalUrl != "" {
|
||||
Config.Http.ExternalUrl = strings.TrimSuffix(Config.Http.ExternalUrl, "/") + "/"
|
||||
Config.Http.ExternalUrl = strings.TrimSuffix(Config.Http.ExternalUrl, Config.Http.BaseURL)
|
||||
if Config.Server.BaseURL != "/" {
|
||||
if Config.Server.ExternalUrl != "" {
|
||||
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, "/") + "/"
|
||||
Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, Config.Server.BaseURL)
|
||||
}
|
||||
if Config.Http.InternalUrl != "" {
|
||||
Config.Http.InternalUrl = strings.TrimSuffix(Config.Http.InternalUrl, "/") + "/"
|
||||
Config.Http.InternalUrl = strings.TrimSuffix(Config.Http.InternalUrl, Config.Http.BaseURL)
|
||||
if Config.Server.InternalUrl != "" {
|
||||
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, "/") + "/"
|
||||
Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, Config.Server.BaseURL)
|
||||
}
|
||||
}
|
||||
Config.Integrations.OnlyOffice.Url = strings.Trim(Config.Integrations.OnlyOffice.Url, "/")
|
||||
@@ -515,7 +432,6 @@ 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.NoAuth {
|
||||
@@ -529,9 +445,6 @@ func setupAuth(generate bool) {
|
||||
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")
|
||||
@@ -549,17 +462,13 @@ func setupAuth(generate bool) {
|
||||
}
|
||||
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
|
||||
if len(Config.Auth.AuthMethods) == 0 {
|
||||
Config.Auth.Methods.PasswordAuth.Enabled = true
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
|
||||
}
|
||||
Config.UserDefaults.Account.LoginMethod = Config.Auth.AuthMethods[0]
|
||||
Config.UserDefaults.LoginMethod = Config.Auth.AuthMethods[0]
|
||||
|
||||
}
|
||||
|
||||
@@ -571,28 +480,23 @@ func setupLogging() {
|
||||
},
|
||||
}
|
||||
}
|
||||
for i := range Config.Server.Logging {
|
||||
cfg := &Config.Server.Logging[i]
|
||||
for _, logConfig := range Config.Server.Logging {
|
||||
// Enable debug logging automatically in dev mode
|
||||
levels := cfg.Levels
|
||||
levels := logConfig.Levels
|
||||
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
|
||||
levels = "info|warning|error|debug"
|
||||
}
|
||||
pattern := cfg.ApiFilter
|
||||
if pattern == "" {
|
||||
pattern = "^/health|^/favicon.ico|^/static|^/public/static"
|
||||
|
||||
logConfig := logger.JsonConfig{
|
||||
Levels: levels,
|
||||
ApiLevels: logConfig.ApiLevels,
|
||||
Output: logConfig.Output,
|
||||
Utc: logConfig.Utc,
|
||||
NoColors: logConfig.NoColors,
|
||||
Json: logConfig.Json,
|
||||
Structured: false,
|
||||
}
|
||||
jsonCfg := logger.JsonConfig{
|
||||
Levels: levels,
|
||||
ApiLevels: cfg.ApiLevels,
|
||||
Output: cfg.Output,
|
||||
Utc: cfg.Utc,
|
||||
NoColors: cfg.NoColors,
|
||||
Json: cfg.Json,
|
||||
Structured: false,
|
||||
ApiPathExclude: pattern,
|
||||
}
|
||||
err := logger.EnableCompatibilityMode(jsonCfg)
|
||||
err := logger.EnableCompatibilityMode(logConfig)
|
||||
if err != nil {
|
||||
log.Println("[ERROR] Failed to set up logger:", err)
|
||||
}
|
||||
@@ -600,7 +504,7 @@ func setupLogging() {
|
||||
}
|
||||
|
||||
func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
Config = SetDefaults(generate)
|
||||
Config = setDefaults(generate)
|
||||
|
||||
// Check if config file exists
|
||||
if _, err := os.Stat(configFile); err != nil {
|
||||
@@ -613,7 +517,7 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
},
|
||||
}
|
||||
loadEnvConfig()
|
||||
return ResolveDatabasePaths()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try multi-file config first (combine all YAML files in the directory)
|
||||
@@ -638,7 +542,6 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
"integrations": true,
|
||||
"frontend": true,
|
||||
"userDefaults": true,
|
||||
"http": true,
|
||||
}
|
||||
|
||||
filteredConfig := make(map[string]interface{})
|
||||
@@ -648,19 +551,6 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
if udRaw, ok := filteredConfig["userDefaults"]; ok {
|
||||
Env.ConfigUserDefaultsSpecified = true
|
||||
if udMap, ok := udRaw.(map[string]interface{}); ok {
|
||||
Env.ConfigUserDefaultsSpecifiedPaths = CollectMapLeafPaths(udMap, "")
|
||||
}
|
||||
}
|
||||
|
||||
// check if database is set in the config
|
||||
val, ok := filteredConfig["server"].(map[string]interface{})["database"].(string)
|
||||
if ok && val != "" {
|
||||
return fmt.Errorf("old database path is set in the config, please migrate your database to SQLite")
|
||||
}
|
||||
|
||||
// Marshal the filtered config back to YAML
|
||||
filteredYAML, err := yaml.Marshal(filteredConfig)
|
||||
if err != nil {
|
||||
@@ -674,67 +564,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()
|
||||
|
||||
@@ -824,38 +657,35 @@ func loadEnvConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
if _, err := os.Stat(database); os.IsNotExist(err) {
|
||||
logger.Warning("database file could not be found. If this is unexpected, please set the FILEBROWSER_DATABASE environment variable to the correct path.")
|
||||
}
|
||||
|
||||
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{},
|
||||
CacheDir: "tmp",
|
||||
MaxArchiveSizeGB: 20,
|
||||
IndexSqlConfig: IndexSqlConfig{
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
StartupIntegrityCheck: IndexStartupIntegrityQuickCheck,
|
||||
WalMode: false,
|
||||
BatchSize: 1000,
|
||||
CacheSizeMB: 32,
|
||||
DisableReuse: false,
|
||||
},
|
||||
Filesystem: Filesystem{
|
||||
CreateFilePermission: "644",
|
||||
@@ -877,27 +707,25 @@ 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,
|
||||
DeleteAfterArchive: true,
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
HideFilesInTree: false,
|
||||
PreferEditorForMarkdown: false,
|
||||
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{
|
||||
Image: boolPtr(true),
|
||||
@@ -906,45 +734,15 @@ func SetDefaults(generate bool) Settings {
|
||||
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
|
||||
@@ -1018,26 +816,6 @@ func loadCustomFavicon() {
|
||||
Env.FaviconPath = validatedPath
|
||||
Env.FaviconIsCustom = true
|
||||
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
|
||||
// PWA and platform PNGs are generated at server startup from this path (SVG uses a raster
|
||||
// sidecar next to the .svg when present; see icons.GeneratePWAIcons).
|
||||
}
|
||||
|
||||
// PWAIconsCacheDir is where startup icon generation writes PNGs (under the server cache dir).
|
||||
func PWAIconsCacheDir() string {
|
||||
return filepath.Join(Config.Server.CacheDir, "icons")
|
||||
}
|
||||
|
||||
// Where transient archives for multi-request (Range) downloads are stored to support chunked downloads.
|
||||
func DownloadCacheDir() string {
|
||||
return filepath.Join(Config.Server.CacheDir, "downloads")
|
||||
}
|
||||
|
||||
// PrepareDownloadSpoolDir creates the download spool directory and removes any leftover dl-archive-*
|
||||
func PrepareDownloadSpoolDir() error {
|
||||
if err := os.MkdirAll(DownloadCacheDir(), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutils.ClearDirectoryContents(DownloadCacheDir())
|
||||
}
|
||||
|
||||
func loadLoginIcon() {
|
||||
@@ -1069,9 +847,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{
|
||||
@@ -1139,6 +919,12 @@ func setConditionals(config *Source) {
|
||||
if rule.IncludeRootItem != "" {
|
||||
resolved.IncludeRootItems[rule.IncludeRootItem] = struct{}{}
|
||||
}
|
||||
if rule.FileNames != "" {
|
||||
resolved.FileNames[rule.FileNames] = rule
|
||||
}
|
||||
if rule.FolderNames != "" {
|
||||
resolved.FolderNames[rule.FolderNames] = rule
|
||||
}
|
||||
if rule.FileName != "" {
|
||||
resolved.FileNames[rule.FileName] = rule
|
||||
}
|
||||
@@ -1171,12 +957,16 @@ func modifyExcludeInclude(config *Source) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Helper to normalize a name-based value (FileNames, FolderNames, StartsWith, EndsWith)
|
||||
// These match against baseName, so no leading slash
|
||||
normalizeName := func(value string) string {
|
||||
// Just trim slashes - names shouldn't have slashes
|
||||
return strings.Trim(value, "/")
|
||||
}
|
||||
|
||||
// Normalize []ConditionalRule slices for full paths
|
||||
allRules := config.Config.Rules
|
||||
// Handle both old format (Conditionals.ItemRules) and new format (Rules)
|
||||
allRules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...)
|
||||
|
||||
for i, rule := range allRules {
|
||||
// normalize full paths
|
||||
@@ -1199,10 +989,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 {
|
||||
@@ -1,9 +1,8 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// InitializeUserResolvers sets up the global resolvers and config providers in the users package
|
||||
@@ -13,7 +12,8 @@ func InitializeUserResolvers() {
|
||||
users.SetSourceNameResolver(func(sourceName string) (string, error) {
|
||||
source, ok := Config.Server.NameToSource[sourceName]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("source name not found: %s", sourceName)
|
||||
logger.Debug("Could not get scope from source name: ", sourceName)
|
||||
return "", nil
|
||||
}
|
||||
return source.Path, nil
|
||||
})
|
||||
@@ -53,7 +53,7 @@ func InitializeUserResolvers() {
|
||||
}
|
||||
return sources
|
||||
},
|
||||
GetDefaultScopes: func() []users.BackendScope {
|
||||
GetDefaultScopes: func() []users.SourceScope {
|
||||
return Config.UserDefaults.DefaultScopes
|
||||
},
|
||||
})
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -28,45 +27,6 @@ type SecretFieldsMap map[string]map[string]bool
|
||||
// DeprecatedFieldsMap[typeName][fieldName] = true if field is deprecated
|
||||
type DeprecatedFieldsMap map[string]map[string]bool
|
||||
|
||||
var (
|
||||
collectCommentsCacheMu sync.RWMutex
|
||||
collectCommentsCache = map[string]collectCommentsCacheEntry{}
|
||||
)
|
||||
|
||||
type collectCommentsCacheEntry struct {
|
||||
comments CommentsMap
|
||||
secrets SecretFieldsMap
|
||||
deprecated DeprecatedFieldsMap
|
||||
}
|
||||
|
||||
func cloneCommentsMap(src CommentsMap) CommentsMap {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(CommentsMap, len(src))
|
||||
for typeName, fieldMap := range src {
|
||||
out[typeName] = make(map[string]string, len(fieldMap))
|
||||
for fieldName, comment := range fieldMap {
|
||||
out[typeName][fieldName] = comment
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneBoolFieldMap(src map[string]map[string]bool) map[string]map[string]bool {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]map[string]bool, len(src))
|
||||
for typeName, fieldMap := range src {
|
||||
out[typeName] = make(map[string]bool, len(fieldMap))
|
||||
for fieldName, v := range fieldMap {
|
||||
out[typeName][fieldName] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getStringStyle determines whether a string should be quoted in YAML
|
||||
func getStringStyle(value string) yaml.Style {
|
||||
// Always quote all strings for consistency
|
||||
@@ -81,38 +41,42 @@ func CollectComments(srcPath string) (CommentsMap, error) {
|
||||
|
||||
// CollectCommentsAndSecrets parses all Go source and returns comments, secrets, and deprecated field mappings
|
||||
func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
|
||||
// Determine directories to parse - include settings and users packages
|
||||
dir := srcPath
|
||||
if fi, err := os.Stat(srcPath); err == nil && !fi.IsDir() {
|
||||
if filepath.IsAbs(srcPath) {
|
||||
// If it's an absolute path to a file, get the directory
|
||||
dir = filepath.Dir(srcPath)
|
||||
}
|
||||
settingsDir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
collectCommentsCacheMu.RLock()
|
||||
if cached, ok := collectCommentsCache[settingsDir]; ok && shouldCacheCollectComments(settingsDir) {
|
||||
collectCommentsCacheMu.RUnlock()
|
||||
return cloneCommentsMap(cached.comments), cloneBoolFieldMap(cached.secrets), cloneBoolFieldMap(cached.deprecated), nil
|
||||
}
|
||||
collectCommentsCacheMu.RUnlock()
|
||||
|
||||
// settings lives at backend/pkg/settings; users at backend/internal/database/users
|
||||
// List of directories to parse for comments
|
||||
dirsToparse := []string{
|
||||
settingsDir,
|
||||
filepath.Join(settingsDir, "..", "..", "internal", "database", "users"),
|
||||
dir, // settings package
|
||||
}
|
||||
|
||||
// Add users package directory
|
||||
// From common/settings, go up to backend root, then into database/users
|
||||
var usersDir string
|
||||
if filepath.IsAbs(dir) {
|
||||
// If absolute path, calculate relative to it
|
||||
usersDir = filepath.Join(filepath.Dir(filepath.Dir(dir)), "database/users")
|
||||
} else {
|
||||
// If relative path like "common/settings" or ".", calculate from current working directory
|
||||
usersDir = "database/users"
|
||||
}
|
||||
if absUsersDir, err := filepath.Abs(usersDir); err == nil {
|
||||
dirsToparse = append(dirsToparse, absUsersDir)
|
||||
}
|
||||
|
||||
comments := make(CommentsMap)
|
||||
secrets := make(SecretFieldsMap)
|
||||
deprecated := make(DeprecatedFieldsMap)
|
||||
|
||||
// Parse each directory
|
||||
for _, parseDir := range dirsToparse {
|
||||
if _, err := os.Stat(parseDir); err != nil {
|
||||
continue
|
||||
}
|
||||
dirComments, dirSecrets, dirDeprecated, err := parseDirectoryComments(parseDir)
|
||||
if err != nil {
|
||||
// Log error but continue with other directories
|
||||
fmt.Printf("Warning: failed to parse directory %s: %v\n", parseDir, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -145,26 +109,7 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
|
||||
}
|
||||
}
|
||||
|
||||
entry := collectCommentsCacheEntry{
|
||||
comments: cloneCommentsMap(comments),
|
||||
secrets: cloneBoolFieldMap(secrets),
|
||||
deprecated: cloneBoolFieldMap(deprecated),
|
||||
}
|
||||
collectCommentsCacheMu.Lock()
|
||||
if shouldCacheCollectComments(settingsDir) {
|
||||
collectCommentsCache[settingsDir] = entry
|
||||
}
|
||||
collectCommentsCacheMu.Unlock()
|
||||
|
||||
return cloneCommentsMap(comments), cloneBoolFieldMap(secrets), cloneBoolFieldMap(deprecated), nil
|
||||
}
|
||||
|
||||
// shouldCacheCollectComments avoids caching parses of ephemeral test fixture dirs under os.TempDir().
|
||||
func shouldCacheCollectComments(settingsDir string) bool {
|
||||
if _, err := os.Stat(filepath.Join(settingsDir, "structs.go")); err == nil {
|
||||
return true
|
||||
}
|
||||
return !strings.HasPrefix(settingsDir, os.TempDir())
|
||||
return comments, secrets, deprecated, nil
|
||||
}
|
||||
|
||||
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
|
||||
@@ -173,9 +118,6 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if _, err = os.Stat(absDir); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var files []*ast.File
|
||||
cfg := &packages.Config{
|
||||
@@ -344,7 +286,7 @@ func buildPathMapRecursive(structType reflect.Type, structValue reflect.Value, c
|
||||
fieldType := field.Type
|
||||
|
||||
// Handle pointers
|
||||
if fieldType.Kind() == reflect.Pointer {
|
||||
if fieldType.Kind() == reflect.Ptr {
|
||||
if fieldValue.IsNil() {
|
||||
// Skip nil pointers
|
||||
continue
|
||||
@@ -537,7 +479,7 @@ func CollectCommentsFromEmbeddedYaml(yamlContent string) (CommentsMap, SecretFie
|
||||
// parseDefaultsFromEmbeddedYaml creates a default config by parsing the embedded YAML template
|
||||
func parseDefaultsFromEmbeddedYaml(embeddedYaml string) (*Settings, error) {
|
||||
// Start with baseline defaults from setDefaults to ensure all fields are initialized
|
||||
defaultConfig := SetDefaults(true)
|
||||
defaultConfig := setDefaults(true)
|
||||
defaultConfig.Server.Sources = []*Source{{Path: "."}}
|
||||
|
||||
// Parse the embedded YAML to overlay the documented defaults
|
||||
@@ -551,10 +493,10 @@ func parseDefaultsFromEmbeddedYaml(embeddedYaml string) (*Settings, error) {
|
||||
// readEmbeddedYaml attempts to read the embedded config.generated.yaml file
|
||||
func readEmbeddedYaml() (string, error) {
|
||||
// Try to read from the relative path that should work when the binary includes embedded files
|
||||
embeddedContent, err := os.ReadFile("internal/web/dist/config.generated.yaml")
|
||||
embeddedContent, err := os.ReadFile("http/dist/config.generated.yaml")
|
||||
if err != nil {
|
||||
// Try alternative path
|
||||
embeddedContent, err = os.ReadFile("backend/internal/web/dist/config.generated.yaml")
|
||||
embeddedContent, err = os.ReadFile("backend/http/dist/config.generated.yaml")
|
||||
if err != nil {
|
||||
// Try current directory path
|
||||
embeddedContent, err = os.ReadFile("dist/config.generated.yaml")
|
||||
@@ -574,7 +516,7 @@ func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
|
||||
// buildNodeWithDefaults constructs a yaml.Node for any Go value, skipping fields that match defaults, redacting secrets, and filtering deprecated fields
|
||||
func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.Value, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
|
||||
// Dereference pointers
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
||||
}
|
||||
@@ -848,7 +790,7 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
|
||||
elemType := v.Type().Elem()
|
||||
// Check if element is a struct or pointer to struct
|
||||
isStructSlice := elemType.Kind() == reflect.Struct ||
|
||||
(elemType.Kind() == reflect.Pointer && elemType.Elem().Kind() == reflect.Struct)
|
||||
(elemType.Kind() == reflect.Ptr && elemType.Elem().Kind() == reflect.Struct)
|
||||
if !isStructSlice {
|
||||
seq.Style = yaml.FlowStyle
|
||||
}
|
||||
@@ -944,7 +886,7 @@ func GenerateYaml() {
|
||||
|
||||
// Generate YAML with comments enabled, full config, and deprecated fields filtered
|
||||
// Use multi-directory comment parsing to get comments from all packages
|
||||
yamlContent, err := GenerateConfigYamlWithSource(&Config, true, true, true, "pkg/settings")
|
||||
yamlContent, err := GenerateConfigYamlWithSource(&Config, true, true, true, "common/settings")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error generating YAML: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -961,9 +903,9 @@ func GenerateYaml() {
|
||||
func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filterDeprecated bool) (string, error) {
|
||||
// Try different source paths to handle both runtime and test scenarios
|
||||
sourcePaths := []string{
|
||||
".", // When running tests from settings directory
|
||||
"../pkg/settings", // When running from backend directory
|
||||
"pkg/settings", // Alternative path
|
||||
".", // When running tests from settings directory
|
||||
"../common/settings", // When running from backend directory
|
||||
"common/settings", // Alternative path
|
||||
}
|
||||
|
||||
for _, sourcePath := range sourcePaths {
|
||||
@@ -1053,7 +995,7 @@ func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFul
|
||||
|
||||
// identifySecretFieldsByReflection identifies secret fields by known field names
|
||||
func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets SecretFieldsMap) {
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return
|
||||
}
|
||||
@@ -1088,9 +1030,9 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
|
||||
|
||||
// Recursively check nested structs
|
||||
fieldValue := v.Field(i)
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
nestedTypeName := field.Type.Name()
|
||||
if field.Type.Kind() == reflect.Pointer {
|
||||
if field.Type.Kind() == reflect.Ptr {
|
||||
nestedTypeName = field.Type.Elem().Name()
|
||||
}
|
||||
identifySecretFieldsByReflection(fieldValue, nestedTypeName, secrets)
|
||||
@@ -1100,7 +1042,7 @@ func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets
|
||||
|
||||
// identifyDeprecatedFieldsByReflection identifies deprecated fields by known field names
|
||||
func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, deprecated DeprecatedFieldsMap) {
|
||||
if v.Kind() == reflect.Pointer {
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return
|
||||
}
|
||||
@@ -1130,9 +1072,9 @@ func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, depr
|
||||
|
||||
// Recursively check nested structs
|
||||
fieldValue := v.Field(i)
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Pointer && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
|
||||
nestedTypeName := field.Type.Name()
|
||||
if field.Type.Kind() == reflect.Pointer {
|
||||
if field.Type.Kind() == reflect.Ptr {
|
||||
nestedTypeName = field.Type.Elem().Name()
|
||||
}
|
||||
identifyDeprecatedFieldsByReflection(fieldValue, nestedTypeName, deprecated)
|
||||
@@ -1166,7 +1108,7 @@ func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, e
|
||||
}
|
||||
if err != nil || readErr != nil {
|
||||
// Fallback to setDefaults
|
||||
defaultConfigValue := SetDefaults(true)
|
||||
defaultConfigValue := setDefaults(true)
|
||||
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
|
||||
defaultConfig = &defaultConfigValue
|
||||
}
|
||||
@@ -1259,7 +1201,7 @@ func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull
|
||||
}
|
||||
if err != nil || readErr != nil {
|
||||
// Fallback to setDefaults
|
||||
defaultConfigValue := SetDefaults(true)
|
||||
defaultConfigValue := setDefaults(true)
|
||||
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
|
||||
defaultConfig = &defaultConfigValue
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateConfigYaml_Basic(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Test using the actual source directory structure
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default value
|
||||
DarkMode: &trueVal, // Default value
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This field is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
Key: "secret123", // This is a secret field
|
||||
AdminUsername: "testadmin", // This is a secret field
|
||||
AdminPassword: "testpassword", // This is a secret field
|
||||
},
|
||||
}
|
||||
|
||||
// Test different combinations
|
||||
testCases := []struct {
|
||||
name string
|
||||
showComments bool
|
||||
showFull bool
|
||||
filterDeprecated bool
|
||||
expectSecrets bool
|
||||
expectDeprecated bool
|
||||
}{
|
||||
{
|
||||
name: "API_mode_all_fields",
|
||||
showComments: false,
|
||||
showFull: true,
|
||||
filterDeprecated: false,
|
||||
expectSecrets: true, // Secrets should be hidden
|
||||
expectDeprecated: true, // Deprecated should be shown
|
||||
},
|
||||
{
|
||||
name: "Static_config_filter_deprecated",
|
||||
showComments: true,
|
||||
showFull: true,
|
||||
filterDeprecated: true,
|
||||
expectSecrets: true, // Secrets should be hidden
|
||||
expectDeprecated: false, // Deprecated should be filtered
|
||||
},
|
||||
{
|
||||
name: "Filtered_non_defaults",
|
||||
showComments: false,
|
||||
showFull: false,
|
||||
filterDeprecated: false,
|
||||
expectSecrets: true, // Secrets should be hidden
|
||||
expectDeprecated: false, // Might not appear if matches default
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Use the current directory structure for real testing
|
||||
yamlOutput, err := GenerateConfigYaml(config, tc.showComments, tc.showFull, tc.filterDeprecated)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed: %v", err)
|
||||
}
|
||||
|
||||
// Basic validation - should produce some output
|
||||
if len(yamlOutput) == 0 {
|
||||
t.Fatal("Generated empty YAML")
|
||||
}
|
||||
|
||||
// Test string quoting - string values should be quoted, numbers and booleans should not
|
||||
lines := strings.Split(yamlOutput, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
|
||||
// This is a key-value line, skip comment portion
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
value := strings.TrimSpace(parts[1])
|
||||
// Remove comments
|
||||
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
|
||||
value = strings.TrimSpace(value[:commentIdx])
|
||||
}
|
||||
|
||||
// Skip empty values, arrays, and objects
|
||||
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a number (should NOT be quoted)
|
||||
if reNumber.MatchString(value) {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Numeric value should not be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a boolean (should NOT be quoted)
|
||||
if value == "true" || value == "false" {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Boolean value should not be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
|
||||
if value == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
// Add debug info to see what's wrong
|
||||
t.Logf("DEBUG: Checking value '%s' from line '%s'", value, strings.TrimSpace(line))
|
||||
t.Errorf("String value should be quoted in line: %s (value: %s)", strings.TrimSpace(line), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test secret hiding
|
||||
if tc.expectSecrets {
|
||||
if !strings.Contains(yamlOutput, "**hidden**") {
|
||||
t.Error("Expected secrets to be hidden with **hidden**")
|
||||
}
|
||||
if strings.Contains(yamlOutput, "secret123") || strings.Contains(yamlOutput, "testadmin") || strings.Contains(yamlOutput, "testpassword") {
|
||||
t.Error("Secret values should not appear in output")
|
||||
}
|
||||
}
|
||||
|
||||
// Test deprecated field filtering
|
||||
hasDeprecated := strings.Contains(yamlOutput, "disableOfficePreviewExt")
|
||||
if tc.expectDeprecated && !hasDeprecated {
|
||||
t.Error("Expected deprecated field to be present")
|
||||
}
|
||||
if !tc.expectDeprecated && hasDeprecated && tc.filterDeprecated {
|
||||
t.Error("Expected deprecated field to be filtered out when filterDeprecated=true")
|
||||
}
|
||||
|
||||
// Test comments
|
||||
hasComments := strings.Contains(yamlOutput, "#")
|
||||
if tc.showComments && !hasComments {
|
||||
t.Error("Expected comments to be present when showComments=true")
|
||||
}
|
||||
|
||||
t.Logf("Test case: %s\nGenerated YAML:\n%s\n", tc.name, yamlOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectCommentsAndSecrets_Basic(t *testing.T) {
|
||||
// Test the comment and secret collection directly
|
||||
comments, secrets, deprecated, err := CollectCommentsAndSecrets(".")
|
||||
if err != nil {
|
||||
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
|
||||
}
|
||||
|
||||
// Should find some secrets in the actual source files
|
||||
foundSecrets := false
|
||||
for typeName, typeSecrets := range secrets {
|
||||
for fieldName := range typeSecrets {
|
||||
t.Logf("Found secret: %s.%s", typeName, fieldName)
|
||||
foundSecrets = true
|
||||
}
|
||||
}
|
||||
if !foundSecrets {
|
||||
t.Error("Expected to find some secret fields in the actual source code")
|
||||
}
|
||||
|
||||
// Should find some deprecated fields
|
||||
foundDeprecated := false
|
||||
for typeName, typeDeprecated := range deprecated {
|
||||
for fieldName := range typeDeprecated {
|
||||
t.Logf("Found deprecated: %s.%s", typeName, fieldName)
|
||||
foundDeprecated = true
|
||||
}
|
||||
}
|
||||
if !foundDeprecated {
|
||||
t.Error("Expected to find some deprecated fields in the actual source code")
|
||||
}
|
||||
|
||||
t.Logf("Comments: %d types", len(comments))
|
||||
t.Logf("Secrets: %d types", len(secrets))
|
||||
t.Logf("Deprecated: %d types", len(deprecated))
|
||||
}
|
||||
|
||||
func TestGenerateYaml_StaticGeneration(t *testing.T) {
|
||||
// Test the static generation function that's used by FILEBROWSER_GENERATE_CONFIG=true
|
||||
|
||||
// Create a temporary config for testing
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "en",
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This should be filtered out
|
||||
},
|
||||
Auth: Auth{
|
||||
Key: "test-secret", // This should be redacted
|
||||
},
|
||||
}
|
||||
|
||||
// Test static generation with comments and deprecated filtering
|
||||
yamlOutput, err := GenerateConfigYamlWithSource(config, true, true, true, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("Static generation failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify comments are included
|
||||
if !strings.Contains(yamlOutput, "#") {
|
||||
t.Error("Static generation should include comments")
|
||||
}
|
||||
|
||||
// Verify deprecated field is filtered out
|
||||
if strings.Contains(yamlOutput, "disableOfficePreviewExt") {
|
||||
t.Error("Static generation should filter out deprecated field disableOfficePreviewExt")
|
||||
}
|
||||
|
||||
// Verify secrets are redacted
|
||||
if !strings.Contains(yamlOutput, "**hidden**") {
|
||||
t.Error("Static generation should redact secrets")
|
||||
}
|
||||
if strings.Contains(yamlOutput, "test-secret") {
|
||||
t.Error("Static generation should not contain actual secret values")
|
||||
}
|
||||
|
||||
// Verify it has proper structure
|
||||
if !strings.Contains(yamlOutput, "userDefaults:") {
|
||||
t.Error("Static generation should contain userDefaults section")
|
||||
}
|
||||
if !strings.Contains(yamlOutput, "auth:") {
|
||||
t.Error("Static generation should contain auth section")
|
||||
}
|
||||
|
||||
t.Logf("Static generation successful with comments and proper filtering")
|
||||
}
|
||||
+44
-62
@@ -2,7 +2,6 @@ package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -25,14 +24,23 @@ type TestNested struct {
|
||||
NestedDeprecated string `json:"nestedDeprecated"` // deprecated: nested deprecated field
|
||||
}
|
||||
|
||||
// Helper to create test source files for comment parsing (isolated dir per test for go/packages).
|
||||
// Helper to create test source files for comment parsing
|
||||
func createTestSourceFile(t *testing.T, content string) (string, func()) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "fixture.go")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
tmpFile, err := os.CreateTemp("", "test_*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path, func() {}
|
||||
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
cleanup := func() {
|
||||
os.Remove(tmpFile.Name())
|
||||
}
|
||||
|
||||
return tmpFile.Name(), cleanup
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_StringQuoting(t *testing.T) {
|
||||
@@ -60,9 +68,7 @@ type Auth struct {
|
||||
// Create real Settings with string values to test quoting
|
||||
settings := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
Locale: "en-US",
|
||||
},
|
||||
Locale: "en-US",
|
||||
},
|
||||
Auth: Auth{
|
||||
AdminUsername: "admin",
|
||||
@@ -206,10 +212,8 @@ type UserDefaults struct {
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: &trueVal,
|
||||
Locale: "es",
|
||||
},
|
||||
DarkMode: &trueVal, // This matches default
|
||||
Locale: "es", // This differs from default ("en")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -245,9 +249,7 @@ func TestGenerateConfigYaml_CommentsOnOff(t *testing.T) {
|
||||
// Use the actual settings source directory since comments work there
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
Locale: "en",
|
||||
},
|
||||
Locale: "en",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -285,13 +287,9 @@ func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
Locale: "es",
|
||||
DarkMode: &trueVal,
|
||||
},
|
||||
FileViewer: UserDefaultsFileViewer{
|
||||
DisableOnlyOfficeExt: ".docx .xlsx",
|
||||
},
|
||||
Locale: "es", // Non-default
|
||||
DarkMode: &trueVal, // Default
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
Key: "secret123", // This is secret
|
||||
@@ -316,7 +314,7 @@ func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
showFull: true,
|
||||
filterDeprecated: false,
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectDeprecated: false,
|
||||
expectDeprecated: true, // deprecated should be shown
|
||||
expectFull: true, // all fields shown
|
||||
expectComments: false, // no comments
|
||||
},
|
||||
@@ -442,10 +440,8 @@ func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
|
||||
name: "only_defaults",
|
||||
config: &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: boolPtr(true), // default value
|
||||
Locale: "en", // default value
|
||||
},
|
||||
DarkMode: boolPtr(true), // default value
|
||||
Locale: "en", // default value
|
||||
},
|
||||
},
|
||||
desc: "Config with only default values",
|
||||
@@ -454,42 +450,28 @@ func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Spot-check flag combinations (full cartesian product is slow without comment-parse cache)
|
||||
cases := []struct {
|
||||
showComments, showFull, filterDeprecated bool
|
||||
}{
|
||||
{false, true, false},
|
||||
{true, true, true},
|
||||
{false, false, true},
|
||||
}
|
||||
if testing.Short() {
|
||||
cases = cases[:1]
|
||||
}
|
||||
for _, tc := range cases {
|
||||
yamlOutput, err := GenerateConfigYaml(tt.config, tc.showComments, tc.showFull, tc.filterDeprecated)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed for %s (comments=%v, full=%v, filterDeprecated=%v): %v",
|
||||
tt.desc, tc.showComments, tc.showFull, tc.filterDeprecated, err)
|
||||
}
|
||||
if yamlOutput == "" {
|
||||
t.Errorf("Generated empty YAML for %s", tt.desc)
|
||||
}
|
||||
if strings.Contains(yamlOutput, "Error:") || strings.Contains(yamlOutput, "ERROR:") {
|
||||
t.Errorf("YAML contains error for %s", tt.desc)
|
||||
// Test all combinations
|
||||
for _, showComments := range []bool{false, true} {
|
||||
for _, showFull := range []bool{false, true} {
|
||||
for _, filterDeprecated := range []bool{false, true} {
|
||||
yamlOutput, err := GenerateConfigYaml(tt.config, showComments, showFull, filterDeprecated)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed for %s (comments=%v, full=%v, filterDeprecated=%v): %v",
|
||||
tt.desc, showComments, showFull, filterDeprecated, err)
|
||||
}
|
||||
|
||||
// Basic validation - should produce valid YAML structure
|
||||
if yamlOutput == "" {
|
||||
t.Errorf("Generated empty YAML for %s", tt.desc)
|
||||
}
|
||||
|
||||
// Should not contain obvious error messages (but allow "error" in log levels etc)
|
||||
if strings.Contains(yamlOutput, "Error:") || strings.Contains(yamlOutput, "ERROR:") {
|
||||
t.Errorf("YAML contains error for %s", tt.desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_defaultPermissionsDocumentsView(t *testing.T) {
|
||||
cfg := SetDefaults(true)
|
||||
cfg.Server.Sources = []*Source{{Path: ".", Config: SourceConfig{}}}
|
||||
out, err := GenerateConfigYamlWithSource(&cfg, true, true, true, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "defaultPermissions:") || !strings.Contains(out, "view:") {
|
||||
t.Fatalf("expected defaultPermissions to include view, got excerpt around block")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 815 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user