Compare commits

..
Author SHA1 Message Date
Graham Steffaniak 8f6c872ee7 hide replace option for users without modify permission (#2966) 2026-09-17 15:14:30 -05:00
Graham Steffaniak 79de9204b5 Merge branch 'dev/v2.0.7' of github.com:gtsteffaniak/filebrowser into dev/v2.0.7 2026-09-17 15:04:06 -05:00
Graham Steffaniakandzumbiepig 07a297a937 Update deps v2.0.7 (#2965)
Co-authored-by: zumbiepig <121742281+zumbiepig@users.noreply.github.com>
2026-09-17 15:01:49 -05:00
Graham Steffaniak 012e514017 Merge branch 'dev/v2.0.7' of github.com:gtsteffaniak/filebrowser into dev/v2.0.7 2026-09-17 14:38:16 -05:00
Graham Steffaniakandzumbiepig f654699b99 Backport risc v release (#2964)
Co-authored-by: zumbiepig <121742281+zumbiepig@users.noreply.github.com>
2026-09-17 14:38:10 -05:00
Graham Steffaniak ae7b2db598 Merge branch 'dev/v2.0.7' of github.com:gtsteffaniak/filebrowser into dev/v2.0.7 2026-09-17 14:37:02 -05:00
Graham Steffaniak 214683ae2e fix a few usage reporting bugs (#2963) 2026-09-17 14:36:43 -05:00
Graham Steffaniak f03534db06 Merge branch 'main' into dev/v2.0.7 2026-09-17 13:54:06 -05:00
zumbiepig 8488c42f5f Add cli init command (#2957) 2026-09-17 13:42:33 -05:00
Graham Steffaniak b07300a57d Popup debounce (#2961) 2026-09-16 18:49:44 -05:00
Graham Steffaniak 42f82811e7 detect more platform types (#2952) 2026-09-16 18:38:22 -05:00
Matt Van HornandMatt Van Horn 6fffdd3840 fix: preserve Ctrl-click file selection when keyboard state is stale (#2958)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-09-16 18:11:35 -05:00
Graham Steffaniak 7aebbfe04c updated changelog 2026-09-12 18:44:27 -05:00
yoo hyeong chan fa58eac9c0 fix(upload): avoid false stalls during parallel transfers (#2950) 2026-09-12 18:42:26 -05:00
752ff247d3 Beta/v2.0.6 (#2946)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Aditya Raj Singh <aditya@bncw.in>
2026-09-12 18:31:24 -05:00
Vladimir Babin 1d08a4549b Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2928) 2026-09-09 12:41:25 -05:00
Graham SteffaniakandKevin B. b12add4317 Beta/v2.0.5 (#2916)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-09-07 12:55:40 -05:00
Graham Steffaniak 5610cdd2bf fix onlyoffice csp security regression (#2876) (#2900) 2026-09-03 20:31:53 -05:00
Ethan Lee Qi Yang ea85c50946 fix(contributing): typo from improtant to important (#2873) 2026-08-30 08:48:32 -05:00
be47c7a1e7 Beta/v2.0.3 (#2870)
Co-authored-by: Aditya Raj Singh <github@adityaraj.com>
Co-authored-by: tbsagdic <86909795+tbsagdic@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:53:51 -05:00
609e1d6482 Beta/v2.0.2 (#2845)
Co-authored-by: yzxcj797 <54314860+yzxcj797@users.noreply.github.com>
Co-authored-by: yzxcj797 <yzxcj797@users.noreply.github.com>
2026-08-24 19:33:27 -05:00
3c8c02414e Beta/v2.0.1 (#2800)
Co-authored-by: Mattijs Bliek <mattijs@mattijsbliek.com>
Co-authored-by: GammelSami <39372285+GammelSami@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-15 11:06:07 -05:00
Graham Steffaniak 29f9055db9 Update README.md (#2748) 2026-08-07 16:25:25 -05:00
+10 3c02eedca6 Beta/v2.0.0 (#2746)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Gabriel Górski <me@glaeqen.eu>
Co-authored-by: Joel Godfrey <82655889+BaccanoMob@users.noreply.github.com>
Co-authored-by: Shane Chiu <71911511+pwn2ooown@users.noreply.github.com>
Co-authored-by: carloda <35763669+carloda@users.noreply.github.com>
Co-authored-by: Taufik Hidayat <taufikdev88@gmail.com>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: SIDDHU SINGH <siddhu3116@gmail.com>
Co-authored-by: Gaurav Kalal <117557391+Gorupa@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
Co-authored-by: Anatolii Prylutskyi <anpryl@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Graham Steffaniak <graham@debian-lake.ghome.net>
Co-authored-by: Neil <kylelzzz02@icloud.com>
Co-authored-by: Graham <info@quantumx-apps.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:24:38 -05:00
Graham Steffaniak 8c5c92bef0 fixed webdav api token creation bug (#2740) (#2744) 2026-08-07 13:53:36 -05:00
b6c9cc9762 Beta/v1.5.4 (#2705)
Co-authored-by: Vadym <63876858+myUdav4iik@users.noreply.github.com>
Co-authored-by: e-tang <eric@tyo.com.au>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 12:39:44 -05:00
42c71c75cb Beta/v1.5.3 (#2700)
Co-authored-by: Vadym <63876858+myUdav4iik@users.noreply.github.com>
Co-authored-by: e-tang <eric@tyo.com.au>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 10:29:56 -05:00
Graham SteffaniakandKevin B. 9503b532e1 Beta/v1.5.2 (#2645)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-07-17 20:09:28 -05:00
Graham Steffaniak a825ddd301 Update README.md (#2646) 2026-07-17 19:08:18 -05:00
da085bca69 Beta/v1.5.1 (#2630)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Graham <info@quantumx-apps.com>
2026-07-13 11:51:51 -05:00
Stephan Paternotte 64250ffff0 Update nl.json (#2603) 2026-07-05 08:18:28 -05:00
Graham Steffaniak 0bc8889c00 Update README.md (#2570) 2026-06-23 10:18:23 -05:00
6eaead073a Beta/v1.5.0 (#2546)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-06-19 11:17:57 -05:00
Graham Steffaniak 826b060bb1 Beta/v1.4.4 (#2536) 2026-06-11 10:52:01 -05:00
21dfc6169e Beta/v1.4.3 (#2530)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Graham Steffaniak <graham@debian-lake.ghome.net>
Co-authored-by: Neil <kylelzzz02@icloud.com>
Co-authored-by: Graham <info@quantumx-apps.com>
Co-authored-by: Anatolii Prylutskyi <anpryl@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:36:10 -05:00
4708faec5f fix(preview): keep opened file selected after closing its preview (#2515)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-06-09 14:48:04 -05:00
fa5abc8c67 Beta/v1.4.2 (#2485)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-22 11:14:27 -05:00
687dc01293 Update en.json (#2456)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 14:43:31 -05:00
bfde65a41e Update nl.json (#2448)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: codacy-production[bot] <61871480+codacy-production[bot]@users.noreply.github.com>
2026-05-18 14:43:18 -05:00
1802e12811 updated to fix critical bug (#2424) (#2427)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
2026-05-14 10:47:26 -05:00
0f215333ee Beta/v1.4.0 (#2421)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
Co-authored-by: Juan Soler <juansoler@gmail.com>
2026-05-13 14:56:35 -05:00
Graham Steffaniak 6d81d1973c Beta/v1.3.10 (#2395) 2026-05-08 14:06:45 -05:00
Graham Steffaniak 869b640a99 Update auth.go (#2381) 2026-05-04 13:32:54 -05:00
Stephan Paternotte 3b8d179774 Update nl.json (#2383) 2026-05-04 12:26:28 -05:00
Graham Steffaniak 569314cdf4 Beta/v1.3.9 (#2377) 2026-05-01 16:11:48 -05:00
Graham Steffaniak 668efec4d7 added new screenshots (#2371) 2026-04-30 15:50:36 -05:00
Graham Steffaniak a5c81c8e2e Beta/v1.3.8 (#2363) 2026-04-30 11:55:14 -05:00
Graham Steffaniak 88aa337696 Beta/v1.3.7 (#2350) 2026-04-27 16:32:13 -05:00
9b48ce414e Beta/v1.3.6 (#2324)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-20 17:00:10 -05:00
0723edf660 Beta/v1.3.5 (#2316)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-04-18 18:24:10 -05:00
Gaurav Kalal d2165ec0c3 Fix: Prevent portrait mobile overflow for large placeholder loader (#2272) 2026-04-13 09:59:10 -05:00
78c554719f Beta/v1.3.4 (#2273)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
Co-authored-by: SIDDHU SINGH <siddhu3116@gmail.com>
Co-authored-by: Taufik Hidayat <taufikdev88@gmail.com>
2026-04-11 19:48:26 -05:00
Taufik Hidayat aa8f9e2923 Add resolve nested groupAdmin to oidc auth method (#2260) 2026-04-10 15:39:49 -05:00
298 changed files with 14412 additions and 4356 deletions

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
"version": "22"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
+5
View File
@@ -5,6 +5,9 @@ on:
branches:
- "main"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright
@@ -17,6 +20,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run build
- uses: actions/setup-go@v5
+2
View File
@@ -33,6 +33,8 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- name: Build frontend
working-directory: frontend
run: npm i && npm run build
@@ -1,88 +0,0 @@
name: Promote Beta to Stable
on:
workflow_dispatch:
inputs:
version:
description: "Version to promote (format: 0.0.0)"
required: true
type: string
jobs:
promote-beta-to-stable:
name: Promote Beta to Stable
runs-on: ubuntu-24.04-arm
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }} # Uses Personal Access Token for branch operations
- name: Validate version format
id: validate_version
run: |
VERSION="${{ github.event.inputs.version }}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "❌ ERROR: Version must be in '0.0.0' format."
exit 1
fi
echo "✅ Version format validated: $VERSION"
- name: Check if beta branch exists
id: check_beta_branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
if ! git ls-remote --exit-code origin "$BETA_BRANCH"; then
echo "❌ ERROR: Beta branch '$BETA_BRANCH' does not exist!"
exit 1
fi
echo "✅ Beta branch '$BETA_BRANCH' exists."
- name: Check if stable branch already exists
id: check_stable_branch
run: |
VERSION="${{ github.event.inputs.version }}"
STABLE_BRANCH="stable/v$VERSION"
if git ls-remote --exit-code origin "$STABLE_BRANCH"; then
echo "❌ ERROR: Stable branch '$STABLE_BRANCH' already exists!"
exit 1
fi
echo "✅ Stable branch '$STABLE_BRANCH' does not exist."
- name: Create and push new stable branch
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
STABLE_BRANCH="stable/v$VERSION"
# Fetch latest changes
git fetch origin "$BETA_BRANCH"
# Create new stable branch from beta branch
git checkout -b "$STABLE_BRANCH" origin/"$BETA_BRANCH"
# Push new stable branch to remote
git push origin "$STABLE_BRANCH"
echo "✅ Successfully created stable branch '$STABLE_BRANCH' from '$BETA_BRANCH'."
- name: Merge beta into stable
run: |
VERSION="${{ github.event.inputs.version }}"
BETA_BRANCH="beta/v$VERSION"
STABLE_BRANCH="stable/v$VERSION"
# Checkout stable branch
git checkout "$STABLE_BRANCH"
# Merge beta into stable
git merge --no-ff "origin/$BETA_BRANCH" -m "Merge $BETA_BRANCH into $STABLE_BRANCH"
# Push merge changes
git push origin "$STABLE_BRANCH"
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
+10 -5
View File
@@ -22,11 +22,8 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- uses: golangci/golangci-lint-action@v7
with:
version: 'v2.1.6'
working-directory: backend
install-mode: goinstall
- working-directory: backend
run: go tool golangci-lint run --path-prefix=backend
format-backend:
runs-on: ubuntu-24.04-arm
steps:
@@ -41,6 +38,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run lint
test-frontend:
@@ -48,6 +47,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run test
check-translations:
@@ -55,6 +56,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run i18n:check
check-docs:
@@ -71,5 +74,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run icons:check
-66
View File
@@ -1,66 +0,0 @@
name: beta release
on:
push:
branches:
- "beta/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-24.04-arm
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-24.04-arm
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: false
make_latest: false
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
-66
View File
@@ -1,66 +0,0 @@
name: stable release
on:
push:
branches:
- "stable/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-24.04-arm
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-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
- name: Extract branch name
shell: bash
run: |
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
id: extract_branch
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false
make_latest: true
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
+2
View File
@@ -17,6 +17,8 @@ jobs:
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: frontend/.nvmrc
- name: Install dependencies and build frontend
run: npm i && npm run build
working-directory: frontend
+122
View File
@@ -2,6 +2,128 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v2.0.7
**New Features**:
- Add cli init CLI command (#2957)
**Notes**:
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
- Added risc-v to official releases
**Bugfixes**:
- Hide the Replace option on upload/create conflict prompts when the user lacks modify permission (or when a public share disallows replacements), so create-only users are not offered an action that the server rejects (#2837)
- Preserve Ctrl-click file selection when keyboard state is stale (#2958) (#2923)
- Avoid false stalls during parallel transfers (#2950) (#2948) thanks @gudcks0305
- Cap source usage-bar percentage at 100% when indexed size exceeds partition total (#2761) (#2238)
- On Linux, source partition totals sum distinct filesystems mounted under the source root (nested mounts) so usage bars match indexed content (#2761)
## v2.0.6
**Security**:
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
**Notes**:
- renaming sources updates sidebar links (#2878)
- disabling/deleting a user source removes that source from sidebar links (#2942)
**Bugfixes**:
- Fix disk-usage overstatement on virtiofs bind mounts (#2894) (#2894)
- Support non-ASCII share passwords (#2933)
- Fall back to buffered copies when FUSE rejects fast paths (#2938) (#2924)
- Preserve deleted sidebar links across restarts (#2935)
## v2.0.5
**New Features**:
- Users can set default view mode and thumbnail size from Profile settings (Listing options). Admins can set the same defaults for existing users from the user edit prompt. Values persist to the account and apply on new browsers or devices; they do not override view preferences already saved locally ([#2884](https://github.com/gtsteffaniak/filebrowser/issues/2884)).
- Admins can edit all profile preference defaults for existing users from the user management panel (listing, preview, sidebar, theme, and related options), matching what was previously available only for new users and global user defaults.
**Security**:
- [Moderate] public metadata api returns file content to anonymous share visitors, ignoring the share's download limit and file-viewer setting (GHSA-55mw-cwg7-m8f5) -- thanks @kta1kri
**Bugfixes**:
- Fixed infinite loading spinner on `/` and `/login` after CSP security hardening ([#2886](https://github.com/gtsteffaniak/filebrowser/issues/2886), [#2890](https://github.com/gtsteffaniak/filebrowser/issues/2890))
- fixed darkmode styling bug where background wouldn't respect configured settings when not logged in
- fixed styling bug where padding was missing from right side of listing view.
- tilde paths for sources were not properly expanded. (eg "~" for home)
- fixed tooltip not showing up for some circumstances on mobile, improved consistency.
- a few styling fixes for inconsistencies (#2908)
- Disk-usage widget no longer reports PB/TB-scale garbage on Docker Desktop for Mac (virtiofs) bind mounts; space is computed from the filesystem fragment size (`f_frsize`) instead of the optimal-transfer block size (`f_bsize`) on Linux ([#2894](https://github.com/gtsteffaniak/filebrowser/issues/2894)).
## v2.0.4
**Bugfixes**:
- OnlyOffice editor failed to load after the CSP security fix; allow configured `integrations.office.url` and `integrations.office.internalUrl` origins in `script-src` ([#2874](https://github.com/gtsteffaniak/filebrowser/issues/2874)).
## v2.0.3
**Security**:
- [High] Stored XSS via HTML preview: strip `<script>` from sanitized srcdoc, remove `allow-same-origin` from the preview iframe sandbox, set HttpOnly on the session cookie, and add a nonce-based `script-src` CSP on the SPA shell (GHSA-vvm6-jwrf-hgmg) -- thanks @qrn12580
- [Moderate] Non-admin users could PATCH privileged fields on their own account (scopes, permissions, etc.), allowing privilege escalation; restores v1 non-admin field guard on PATCH /api/users (GHSA-p5cc-4p84-c4m2) -- thanks @Chri6s
**New Features**:
- Added option to globally disable the "Install App" message via `frontend.disablePWAInstall`
- PWA improvements for installed mobile apps: dedicated maskable icons (192/512), manifest and splash colors that follow the instance default theme, runtime `theme-color` sync on dark-mode toggle, and edge-to-edge safe-area layout for notched devices (#2625) (#2869) -- thanks @APatenaude
**Bugfixes**:
- Fixed Fuji `.raf` thumbnail preview by extracting the camera-embedded JPEG from the RAF header (regression for files where TIFF-based raw extraction does not apply).
- Image previews for unsupported decodable formats now return HTTP 415 immediately instead of attempting JPEG decode and returning HTTP 500.
- OIDC: preserve the verified ID-token user identifier when falling back to the UserInfo endpoint for missing groups, so login no longer fails with HTTP 500 when UserInfo returns groups but omits the configured identifier.
- LDAP: restore `memberOf` as the default `groupsClaim` when unset, fixing group-based admin and login authorization for existing LDAP configs after the v2.0.2 default changed to `groups`.
- "Install app" message reappears after being cleared on device.
- Restored upload chunk size `0` to disable chunking as documented ([#2202](https://github.com/gtsteffaniak/filebrowser/issues/2202)).
- Fixed iOS 26 / WebKit multi-chunk upload stall by isolating chunk connections and returning partial chunk JSON responses ([#2734](https://github.com/gtsteffaniak/filebrowser/issues/2734)).
- Sidebar folder links with custom names, icons, or styles no longer disappear after restart; multiple shortcuts to different folders on the same source are preserved ([#2809](https://github.com/gtsteffaniak/filebrowser/issues/2809)).
- Adding a source to a user via scopes now auto-adds a matching sidebar link; removing a source keeps the link (shown disabled) so users can delete it manually.
- A password reset via CLI returns user to a password method user.
- Authentication login methods are consistently enforced without modifying existing account settings.
## v2.0.2
**Bugfixes**:
- OIDC: `groupsClaim` value is always included in requested scopes and falls back to the UserInfo endpoint when the ID token omits the groups claim.
- Windows: fix backslash duplication in navigation URLs, folder sizes showing 4 KB, and download/preview failures (#2815, #2816)
- Wrong extension in the 'new database was created popup (#2817) (#2824)
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
- OnlyOffice is inaccessible on password-protected shares (#2811)
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
## v2.0.1
**Security**:
- [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds
- [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo
- [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu)
- [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec
- [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209
**Notes**:
- GroupMap `SyncUserGroups` fix so JWT/OIDC/LDAP group memberships survive restart (#2742).
- Session renew is handled by client keep-alive. removed per-request `X-Renew-Token` header handling.
- Improvements to document thumbnail generation performance.
- behavior changes for typing to select files in listing view.
- next/previous buttons don't hide automatically on photos (#2767)
- added more actions available in advanced search (#2776)
**Bugfixes**:
- GroupMap mutations (`SyncUserGroups`, add/remove group members) are write-through to SQL so JWT/OIDC/LDAP group memberships survive restart (#2742).
- `defaultEnabled` behavior change/fix so defaultEnabled means its always added to users on startup and login. (updated docs too)
- Long uploads/downloads no longer lose the session mid-transfer: proactive session JWT keep-alive renews before expiry (#2638).
- Disable auto logout/session expiry if there are active file transfers going (#2638)
- upload options get cut off on mobile (#2685)
- Uploading image: image gets cut-off (#2765)
- Process aborts (not just 500) during PDF thumbnail generation (#2763)
- PDF preview generation prevents file uploads from completing (#2752)
- Brings back double-tap to zoom images that was mistakenly removed.
- multiple embedded subtitles with same language bug (#2756)
- gallery view download button missing (#2767)
- fixed HEIC rotation regression from v1.5.x
- fixed raw image preview regression from v1.5.x
- html viewer takes full height
- Members without download permission receive 403 when opening text-based files despite OnlyOffice preview being enabled (#2777)
## v2.0.0
This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary, and the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert legacy config.
+1 -1
View File
@@ -7,7 +7,7 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
- **Go 1.25+** (see `backend/go.mod`)
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Docker** (optional, for containerized development)
- **Git** especially improtant on windows -- needed for bash support.
- **Git** especially important on Windows -- needed for bash support.
### Optional Tools
+1
View File
@@ -17,6 +17,7 @@
## Pinned
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
## About
+3 -2
View File
@@ -5,6 +5,7 @@ ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev
RUN go mod download && go run ./scripts/setup-gofitz-cgo
ENV CGO_ENABLED=1
RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=${VERSION}' \
@@ -13,11 +14,11 @@ RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
FROM node:jod-slim AS nbuild
WORKDIR /build
COPY ./frontend/package.json ./frontend/
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN cd frontend && npm i --maxsockets 1
RUN cd frontend && npm ci --maxsockets 1
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
+1 -1
View File
@@ -13,4 +13,4 @@ RUN mkdir -p ./internal/web/dist
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
+2 -2
View File
@@ -11,11 +11,11 @@ RUN go build -tags analytics -ldflags="-w -s \
FROM node:jod-slim AS nbuild
WORKDIR /build
COPY ./frontend/package.json ./frontend/
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN cd frontend && npm i
RUN cd frontend && npm ci
COPY ./frontend/ ./frontend/
WORKDIR /build/frontend
RUN npm run build:docker
@@ -1,4 +1,5 @@
import { defineConfig, devices } from "@playwright/test";
import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
/**
* Read environment variables from file.
@@ -9,7 +10,7 @@ import { defineConfig, devices } from "@playwright/test";
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
export default defineConfig<PlaywrightFixtureOptions>({
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/screenshots",
+1
View File
@@ -2,6 +2,7 @@ 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"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
+2 -1
View File
@@ -2,7 +2,8 @@ root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/filebrowser.exe --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
cmd = "go build -o ./tmp/filebrowser.exe --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
entrypoint = "./tmp/filebrowser.exe"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser.exe -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
+24 -2
View File
@@ -2,6 +2,11 @@
project_name: filebrowser
version: 2
before:
hooks:
- go mod download
- go run ./scripts/setup-gofitz-cgo
builds:
# Build configuration for linux amd64
- id: linux-amd64
@@ -44,6 +49,22 @@ builds:
post:
- upx {{ .Path }}
# Build configuration for linux riscv64
- id: linux-riscv64
ldflags: *ldflags
main: main.go
binary: filebrowser
env:
- CC=zig cc -target riscv64-linux-musl
# go-fitz does not provide riscv64 MuPDF libraries yet: https://github.com/gen2brain/go-fitz/tree/master/libs
tags:
- analytics
- musl
goos:
- linux
goarch:
- riscv64
# Build configuration for linux
- id: linux-arm32
ldflags: *ldflags
@@ -66,7 +87,8 @@ builds:
- id: windows
env:
- CGO_ENABLED=1
- CC=zig cc -target x86_64-windows
# windows-gnu avoids zig rejecting Go's -tsaware when cross-compiling from macOS
- CC=zig cc -target x86_64-windows-gnu -lc
tags:
- analytics
- mupdf
@@ -93,7 +115,7 @@ builds:
goarch:
- amd64
# Build configuration for macos without upx
# Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported)
- id: macos
ldflags: *ldflags
main: main.go
+28 -8
View File
@@ -87,7 +87,7 @@ func TestAdminHasSharePermissionAfterSettingsMigration(t *testing.T) {
assertSourceSidebarLinkCount(t, &admin, 3)
}
func TestGrahamNoAccessAfterSettingsMigrationStartup_dockerSourcePaths(t *testing.T) {
func TestGrahamGainsAccessAfterSettingsMigrationStartup_dockerSourcePaths(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
configPath := filepath.Join(t.TempDir(), "config.yaml")
configYAML := `server:
@@ -136,19 +136,29 @@ auth:
if err != nil {
t.Fatal(err)
}
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
t.Fatalf("graham gained access scope: %#v", graham.BackendScopes)
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" {
t.Fatalf("graham has access sidebar link: %#v", 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 TestGrahamNoAccessAfterSettingsMigrationStartup(t *testing.T) {
func TestGrahamGainsAccessAfterSettingsMigrationStartup(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
@@ -174,16 +184,26 @@ func TestGrahamNoAccessAfterSettingsMigrationStartup(t *testing.T) {
if err != nil {
t.Fatal(err)
}
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
t.Fatalf("graham gained access scope after validateUserInfo: %#v", graham.BackendScopes)
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" {
t.Fatalf("graham has access sidebar link after validateUserInfo: %#v", 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")
}
+10 -4
View File
@@ -115,7 +115,7 @@ func setUser(username, password string, asAdmin bool) error {
return nil
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
logger.Infof("Converting user %s from %s to password login method", username, user.LoginMethod)
}
user.TOTPSecret = ""
user.TOTPNonce = ""
@@ -139,6 +139,9 @@ func promoteUser(username string) error {
if err != nil {
return fmt.Errorf("user %s not found", username)
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s cannot be promoted: only password-login users can be granted admin via CLI", username)
}
if user.Permissions.Admin {
fmt.Printf("user %s is already an admin\n", username)
return nil
@@ -238,11 +241,14 @@ func createConfig(configpath string, noInput bool) error {
}
for {
config.Server.DatabaseV2.Path = askQuestion(reader, "What should the file name and path be for the database?", "./database.db")
if strings.HasSuffix(config.Server.DatabaseV2.Path, ".db") {
// "database.db" is the reserved legacy-bolt filename and is rejected
// at startup (see backend/cmd/migrate.go), so the wizard must not
// suggest it as the default.
config.Server.DatabaseV2.Path = askQuestion(reader, "What should the file name and path be for the database?", "./filebrowser.sqlite")
if strings.HasSuffix(config.Server.DatabaseV2.Path, ".sqlite") {
break
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .db", config.Server.DatabaseV2.Path)
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
}
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
config.Auth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
+8
View File
@@ -40,6 +40,14 @@ func TestParseVersionCommand(t *testing.T) {
assert.Equal(t, "version", ctx.Command())
}
func TestParseInitCommand(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
ctx, err := parser.Parse([]string{"init"})
require.NoError(t, err)
assert.Equal(t, "init", ctx.Command())
}
func TestParseGlobalConfigFlag(t *testing.T) {
cli := freshCLI()
parser := newCLIParser(t, &cli)
+101
View File
@@ -0,0 +1,101 @@
package cmd
import (
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupCLIUserTest(t *testing.T) {
t.Helper()
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.InitializeCLI("../../../_docker/src/noauth/backend/config.yaml")
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
store, _, err := state.Open(dbPath)
if err != nil {
t.Fatal(err)
}
app.MustWireServices(store)
t.Cleanup(func() { _ = state.Close() })
}
func TestSetUserConvertsOIDCToPassword(t *testing.T) {
setupCLIUserTest(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatal(err)
}
if err := setUser("graham", "newpassword", false); err != nil {
t.Fatalf("setUser: %v", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if loaded.LoginMethod != users.LoginMethodPassword {
t.Fatalf("loginMethod = %q, want password", loaded.LoginMethod)
}
}
func TestPromoteUserRejectsNonPasswordLoginMethod(t *testing.T) {
setupCLIUserTest(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatal(err)
}
err := promoteUser("graham")
if err == nil {
t.Fatal("promoteUser: expected error for oidc user")
}
if !strings.Contains(err.Error(), "password-login") {
t.Fatalf("promoteUser err = %q, want password-login restriction", err.Error())
}
}
func TestPromoteUserAllowsPasswordLoginMethod(t *testing.T) {
setupCLIUserTest(t)
passwordUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodPassword,
},
}
if err := state.CreateUser(passwordUser, "secret"); err != nil {
t.Fatal(err)
}
if err := promoteUser("graham"); err != nil {
t.Fatalf("promoteUser: %v", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatal(err)
}
if !loaded.Permissions.Admin {
t.Fatal("expected admin after promote")
}
}
+10 -3
View File
@@ -25,6 +25,8 @@ type versionCmd struct{}
type setupCmd struct{}
type initCmd struct{}
type SetCmd struct {
User string `short:"u" help:"Deprecated: comma-separated username,password. Use 'user set' instead."`
Admin bool `short:"a" help:"Create user as admin (used with -u)"`
@@ -116,6 +118,7 @@ type cliRoot struct {
Run runCmd `cmd:"" default:"1" hidden:"" help:"Start the FileBrowser server"`
Version versionCmd `cmd:"" name:"version" help:"Print version information"`
Setup setupCmd `cmd:"" name:"setup" help:"Interactive configuration setup"`
Init initCmd `cmd:"" name:"init" hidden:"" help:"Initialize the database or migrate from a legacy database"`
Set SetCmd `cmd:"" name:"set" help:"Set configuration values (deprecated: use 'user set' for users)"`
User UserCmd `cmd:"" name:"user" help:"User management"`
}
@@ -133,6 +136,10 @@ func (setupCmd) Run(globals *Globals) error {
return createConfig(globals.Config, globals.NoInput)
}
func (initCmd) Run() error {
return nil
}
func (s *SetCmd) Run(ctx *kong.Context) error {
if strings.HasPrefix(ctx.Command(), "set rule") {
return nil
@@ -210,14 +217,14 @@ func runCLI() (keepGoing bool, dbExists bool) {
switch {
case cmd == "" || cmd == "run":
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath)
dbExists = initializeDatabase(configPath, false)
return true, dbExists
case cmd == "version" || cmd == "setup":
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, false
case cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
case cmd == "init" || cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
requireExistingConfig(configPath)
dbExists = initializeDatabase(configPath)
dbExists = initializeDatabase(configPath, true)
parser.FatalIfErrorf(ctx.Run(&rootCLI))
return false, dbExists
default:
+6 -2
View File
@@ -29,8 +29,12 @@ import (
var runtimeApp *app.App
func initializeDatabase(configFile string) bool {
settings.Initialize(configFile)
func initializeDatabase(configFile string, cliMode bool) bool {
if cliMode {
settings.InitializeCLI(configFile)
} else {
settings.Initialize(configFile)
}
if err := validateDatabasePaths(); err != nil {
logger.Fatalf("%v", err)
+17 -76
View File
@@ -7,10 +7,10 @@ import (
"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/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -27,7 +27,8 @@ func validateUserInfo(newDB bool) {
changedFields := make([]string, 0, 8)
changePass := false
if updateUserScopes(user) {
scopesChanged := updateUserScopes(user)
if scopesChanged {
changedFields = append(changedFields, "backendScopes")
}
if updatePermissions(user) {
@@ -45,7 +46,7 @@ func validateUserInfo(newDB bool) {
if updateShowFirstLogin(user) {
changedFields = append(changedFields, "showFirstLogin")
}
if updateSidebarLinks(user) {
if updateSidebarLinks(user, scopesChanged) {
changedFields = append(changedFields, "sidebarLinks")
}
if updateTokens(user) {
@@ -81,7 +82,8 @@ func validateUserInfo(newDB bool) {
logger.Info("Resetting admin user to default username and password.")
user.Permissions = settings.AdminPerms()
user.Password = settings.Config.Auth.AdminPassword
changedFields = append(changedFields, "permissions", "password")
user.LoginMethod = users.LoginMethodPassword
changedFields = append(changedFields, "permissions", "password", "loginMethod")
changePass = true
}
@@ -127,51 +129,9 @@ func dedupeFields(fields []string) []string {
}
func updateUserScopes(user *users.User) bool {
newScopes := []users.BackendScope{}
seen := make(map[string]struct{})
hadScopes := len(user.BackendScopes) > 0
// Build map for existing scopes by Name
existing := make(map[string]users.BackendScope)
for _, s := range user.BackendScopes {
existing[s.Path] = s
}
// Preserve order by using Config.Server.Sources
for _, src := range settings.Config.Server.Sources {
existingScope, ok := existing[src.Path]
if ok {
// If scope is empty and there's a default, apply default
if existingScope.Scope == "" {
existingScope.Scope = src.Config.DefaultUserScope
}
} else if src.Config.DefaultEnabled {
// Only seed default-enabled sources for users with no scopes yet.
// Partial-scope users (e.g. migrated graham) must not gain extra sources on startup.
if hadScopes {
continue
}
existingScope.Scope = src.Config.DefaultUserScope
} else {
continue
}
newScopes = append(newScopes, users.BackendScope{
Path: src.Path,
Scope: existingScope.Scope,
Permissions: existingScope.Permissions,
})
seen[src.Path] = struct{}{}
}
// Preserve user-defined scopes not matching current sources, append to end
for _, s := range user.BackendScopes {
if _, ok := seen[s.Path]; !ok {
newScopes = append(newScopes, s)
}
}
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
user.BackendScopes = newScopes
return changed
}
@@ -273,37 +233,18 @@ func updatePreviewSettings(user *users.User) bool {
return false
}
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
func updateSidebarLinks(user *users.User) bool {
updated := false
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
updated = true
// updateSidebarLinks normalizes saved links and adds defaults for new or changed scopes.
func updateSidebarLinks(user *users.User, scopesChanged bool) bool {
links, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks)
if scopesChanged || user.SidebarLinks == nil {
links, changed = usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
}
if !changed {
return false
}
if !usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes) {
return updated
}
if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 && len(user.SidebarLinks) > 0 {
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
} else if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 {
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
} else {
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
}
merged, changed := usersidebar.EnsureSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
if changed {
user.SidebarLinks = merged
updated = true
}
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
updated = true
}
return updated
user.SidebarLinks = links
return true
}
func updateTokens(user *users.User) bool {
+38 -21
View File
@@ -10,7 +10,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestUpdateUserScopes_preservesPartialScopes(t *testing.T) {
func TestUpdateUserScopes_mergesMissingDefaultEnabled(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
@@ -22,18 +22,31 @@ func TestUpdateUserScopes_preservesPartialScopes(t *testing.T) {
},
}
updateUserScopes(user)
if len(user.BackendScopes) != 2 {
t.Fatalf("scopes=%#v want 2 partial scopes", user.BackendScopes)
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 {
t.Fatalf("access source should not be added to partial-scope user: %#v", user.BackendScopes)
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_noAccessScopeOrSidebarLink(t *testing.T) {
func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
@@ -59,35 +72,39 @@ func TestMigrationGraham_noAccessScopeOrSidebarLink(t *testing.T) {
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 after migration: %#v", graham.BackendSourcePermissions)
}
for _, link := range usersidebar.FrontendLinks(graham.SidebarLinks, true) {
if link.Name == "access" {
t.Fatalf("graham should not have access sidebar link after migration: %#v", graham.SidebarLinks)
}
t.Fatalf("graham should not have access permissions from migration alone: %#v", graham.BackendSourcePermissions)
}
updateUserScopes(graham)
updateSourcePermissions(graham)
updateSidebarLinks(graham)
updateSidebarLinks(graham, true)
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
t.Fatalf("graham should not gain access scope: %#v", graham.BackendScopes)
foundScope = true
break
}
}
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; ok {
t.Fatalf("graham should not have access source permissions: %#v", graham.BackendSourcePermissions)
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)
}
frontendLinks := usersidebar.FrontendLinks(graham.SidebarLinks, true)
for _, link := range frontendLinks {
if link.Name == "access" {
t.Fatalf("graham should not have access sidebar link: %#v", frontendLinks)
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) {
+36 -2
View File
@@ -33,7 +33,7 @@ func TestUpdateSidebarLinks_dedupesDuplicateMigratedLinks(t *testing.T) {
},
}
if !updateSidebarLinks(user) {
if !updateSidebarLinks(user, false) {
t.Fatal("expected updateSidebarLinks to return true")
}
@@ -63,7 +63,7 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
},
}
if !updateSidebarLinks(user) {
if !updateSidebarLinks(user, false) {
t.Fatal("expected updateSidebarLinks to return true")
}
@@ -77,3 +77,37 @@ func TestUpdateSidebarLinks_buildsFromScopesWhenNoSourceLinks(t *testing.T) {
t.Fatalf("got %d source links, want 3: %v", count, user.SidebarLinks)
}
}
func TestUpdateSidebarLinks_keepsDeletedSourceLinkAbsent(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
user := &users.User{
FrontendUser: users.FrontendUser{
Username: "admin",
NonAdminEditable: users.NonAdminEditable{
SidebarLinks: []users.SidebarLink{
{Name: "playwright + files", Category: "source", SourceName: fixturePlaywrightSource, Target: "/"},
{Name: "docker", Category: "source", SourceName: fixtureDockerSource, Target: "/"},
},
},
},
BackendScopes: []users.BackendScope{
{Path: fixturePlaywrightSource, Scope: "/"},
{Path: fixtureDockerSource, Scope: "/"},
{Path: fixtureAccessSource, Scope: "/"},
},
}
if !updateSidebarLinks(user, false) {
t.Fatal("expected default source names to be blanked on normalize")
}
if len(user.SidebarLinks) != 2 {
t.Fatalf("got %d sidebar links, want 2: %v", len(user.SidebarLinks), user.SidebarLinks)
}
for _, link := range user.SidebarLinks {
if link.Name != "" {
t.Fatalf("expected blank default name, got %#v", link)
}
}
}
+6
View File
@@ -45,9 +45,12 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
},
expectedPhase1: []users.BackendScope{
{Scope: "/home", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
// Phase2: both already present after phase1 merge.
expectedPhase2: []users.BackendScope{
{Scope: "/home", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
{
@@ -61,6 +64,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
{Scope: "/defaultB", Path: "/pathB"},
},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
@@ -95,6 +99,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
{Scope: "/data", Path: "/somethingElse"},
},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/home", Path: "/pathB"},
{Scope: "/data", Path: "/somethingElse"},
},
@@ -108,6 +113,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
{Scope: "/defaultB", Path: "/pathB"},
},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
-10
View File
@@ -1,10 +0,0 @@
userDefaults:
preview:
image: true
video: true
audio: true
motionVideoPreview: true
office: true
popup: true
folder: true
models: true
+123 -119
View File
@@ -1,65 +1,66 @@
module github.com/gtsteffaniak/filebrowser/backend
go 1.26.0
go 1.27.0
require (
github.com/alecthomas/kong v1.16.0
github.com/alecthomas/kong v1.16.1
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.20.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/evanoberholster/imagemeta v1.0.0
github.com/gen2brain/go-fitz v1.28.2
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.4
github.com/go-playground/validator/v10 v10.30.4
github.com/go-webauthn/webauthn v0.18.1
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-ffmpeg v0.5.1
github.com/gtsteffaniak/go-ffmpeg v0.5.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.49
github.com/mattn/go-sqlite3 v1.14.52
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.54.0
golang.org/x/image v0.44.0
golang.org/x/mod v0.38.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.48.0
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/mod v0.41.0
golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/time v0.16.0
golang.org/x/tools v0.50.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.55.0
modernc.org/sqlite v1.59.0
)
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
charm.land/lipgloss/v2 v2.0.3 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
codeberg.org/chavacava/garif v0.2.1 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/golib v0.6.0 // indirect
github.com/4meepo/tagalign v1.4.3 // indirect
github.com/Abirdcfly/dupword v0.1.7 // indirect
dev.gaijin.team/go/exhaustruct/v5 v5.2.0 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
github.com/4meepo/tagalign v1.4.4 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
github.com/Antonboom/errname v1.1.2 // indirect
github.com/Antonboom/nilnil v1.1.2 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
@@ -67,76 +68,75 @@ require (
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alecthomas/go-check-sumtype v0.4.0 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.4 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bits-and-blooms/bitset v1.24.5 // indirect
github.com/bep/helpers v0.12.0 // indirect
github.com/bits-and-blooms/bitset v1.25.0 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.8.0 // indirect
github.com/bombsimon/wsl/v5 v5.9.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/butuzov/mirror v1.3.3 // indirect
github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/daixiang0/gci v0.14.0 // indirect
github.com/dave/dst v0.28.0 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dlclark/regexp2/v2 v2.8.0 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.2 // indirect
github.com/ebitengine/purego v0.11.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/firefart/nonamedreturns v1.0.8 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/ghostiam/protogetter v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-critic/go-critic v0.15.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/spec v0.22.9 // indirect
github.com/go-openapi/swag/conv v0.28.0 // indirect
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
github.com/go-openapi/swag/loading v0.28.0 // indirect
github.com/go-openapi/swag/pools v0.28.0 // indirect
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-openapi/jsonpointer v1.0.1 // indirect
github.com/go-openapi/jsonreference v1.0.2 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
github.com/go-openapi/swag/loading v0.29.2 // indirect
github.com/go-openapi/swag/pools v0.29.2 // indirect
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -147,26 +147,27 @@ require (
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/gohugoio/hugo v0.164.0 // indirect
github.com/godoc-lint/godoc-lint v0.11.4 // indirect
github.com/gofrs/flock v0.13.1 // indirect
github.com/gohugoio/hashstructure v1.1.0 // indirect
github.com/gohugoio/hugo v0.166.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260713102120-857a528af641 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.12.2 // indirect
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
github.com/golangci/golangci-lint/v2 v2.13.2 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect
github.com/golangci/rowserrcheck v0.0.0-20260901154044-241134db7241 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/go-tpm v0.9.8 // indirect
@@ -178,109 +179,104 @@ require (
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jgautheron/goconst v1.10.0 // indirect
github.com/jgautheron/goconst v1.11.0 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kisielk/errcheck v1.20.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.8.0 // indirect
github.com/ldez/gomoddirectives v0.10.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.7.2 // indirect
github.com/ldez/tagliatelle v0.8.0 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.6.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mgechev/revive v1.16.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/exhaustive v0.13.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/raeperd/recvcheck v0.3.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.26.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/securego/gosec/v2 v2.29.0 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/sourcegraph/go-diff v0.9.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
github.com/timonwong/loggercheck v0.12.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.4.2 // indirect
github.com/uudashr/iface v1.5.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xo/terminfo v1.2.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
@@ -288,25 +284,25 @@ require (
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.augendre.info/fatcontext v0.10.1 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20260908205506-85c1c2202aba // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.74.4 // indirect
honnef.co/go/tools v0.8.1 // indirect
modernc.org/libc v1.76.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
modernc.org/memory v1.12.1 // indirect
mvdan.cc/gofumpt v0.12.0 // indirect
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
tool (
@@ -314,3 +310,11 @@ tool (
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
github.com/swaggo/swag/cmd/swag
)
exclude (
github.com/alecthomas/go-check-sumtype v0.5.0
github.com/gobwas/glob v1.0.0
github.com/gtsteffaniak/go-ffmpeg v1.0.0
github.com/gtsteffaniak/go-ffmpeg v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.2.0
)
+296 -744
View File
File diff suppressed because it is too large. Load diff
+32 -12
View File
@@ -15,13 +15,13 @@ import (
"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/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -131,10 +131,10 @@ func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service)
return "", "", fmt.Errorf("user not provided")
}
if opts.Path == "" {
return "", "", fmt.Errorf("path not provided")
return "", "", fmt.Errorf("%w: path not provided", errors.ErrInvalidRequestParams)
}
if opts.Source == "" {
return "", "", fmt.Errorf("source not provided")
return "", "", fmt.Errorf("%w: source not provided", errors.ErrInvalidRequestParams)
}
// Get index
@@ -154,7 +154,7 @@ func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service)
return "", "", errors.ErrAccessDenied
}
indexPath := utils.JoinPathAsUnix(userScope, safePath)
indexPath := utils.JoinScopedIndexPath(userScope, safePath)
parsedPath, err := utils.ParseSanitizedIndexPath(indexPath, true)
if err != nil {
return "", "", errors.ErrAccessDenied
@@ -190,7 +190,7 @@ func GetDirItems(opts utils.FileOptions, user *users.User) (Items, error) {
func getDirItemsImpl(opts utils.FileOptions, user *users.User, s *Service) (Items, error) {
items := Items{}
indexPath, _, topLevelErr := checkPermissionsImpl(opts, user, s)
indexPath, userScope, topLevelErr := checkPermissionsImpl(opts, user, s)
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
if topLevelErr != nil && !accessRulesErr {
return items, topLevelErr
@@ -200,14 +200,18 @@ func getDirItemsImpl(opts utils.FileOptions, user *users.User, s *Service) (Item
if idx == nil {
return items, fmt.Errorf("could not get index: %v ", opts.Source)
}
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
infoReq := indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
HideFileExt: opts.HideFileExt,
Expand: true,
SkipExtendedAttrs: true,
})
}
if opts.FollowSymlinks {
infoReq.BoundIndexPath = userScope
}
info, err := idx.GetFileInfo(infoReq)
if err != nil {
return items, err // Path excluded by index rules OR doesn't exist
}
@@ -261,14 +265,18 @@ 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)
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
fileInfoReq := indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
HideFileExt: opts.HideFileExt,
Expand: opts.Expand,
SkipExtendedAttrs: opts.SkipExtendedAttrs,
})
}
if opts.FollowSymlinks {
fileInfoReq.BoundIndexPath = userScope
}
info, err := idx.GetFileInfo(fileInfoReq)
if err != nil {
return response, err // Path excluded by index rules OR doesn't exist
}
@@ -285,7 +293,15 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*
// Build response
response.FileInfo = *info
response.RealPath = filepath.Join(idx.Path, indexPath)
if opts.FollowSymlinks && info.Type != "directory" {
realPath, _, err := idx.GetRealPathScoped(userScope, indexPath)
if err != nil {
return response, err
}
response.RealPath = realPath
} else {
response.RealPath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
}
response.Source = opts.Source
if user.Permissions.Share && opts.ShowSharedAttr {
for i := range response.Files {
@@ -387,6 +403,10 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
return
}
if !opts.Content {
return
}
// Process text content for non-video, non-audio files
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(info.RealPath)
+15 -2
View File
@@ -1,10 +1,12 @@
package fileutils
import (
"errors"
"io"
"os"
"path"
"path/filepath"
"syscall"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -126,8 +128,7 @@ func copySingleFile(source, dest string) error {
defer dst.Close()
// Copy the contents of the file.
_, err = io.Copy(dst, src)
if err != nil {
if err = copyFileContents(dst, src); err != nil {
return err
}
@@ -146,6 +147,18 @@ func copySingleFile(source, dest string) error {
return nil
}
func copyFileContents(dst io.Writer, src io.Reader) error {
_, err := io.Copy(dst, src)
if !errors.Is(err, syscall.EBADF) {
return err
}
// Some FUSE filesystems reject copy_file_range. Hide the optional fast-path
// methods so io.Copy retries from the current offsets with buffered I/O.
_, err = io.Copy(struct{ io.Writer }{dst}, struct{ io.Reader }{src})
return err
}
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
srcInfo, err := os.Stat(source)
@@ -1,12 +1,33 @@
package fileutils
import (
"bytes"
"io"
"os"
"path/filepath"
"syscall"
"testing"
"time"
)
type copyFileRangeRejectingWriter struct {
bytes.Buffer
readFromCalls int
}
func (w *copyFileRangeRejectingWriter) ReadFrom(r io.Reader) (int64, error) {
w.readFromCalls++
buf := make([]byte, 4)
n, err := r.Read(buf)
if n > 0 {
_, _ = w.Write(buf[:n])
}
if err != nil {
return int64(n), err
}
return int64(n), syscall.EBADF
}
func TestUnixModeToFileMode_setgid(t *testing.T) {
m := unixModeToFileMode(0o2770)
if got := m.Perm(); got != 0o770 {
@@ -117,3 +138,19 @@ func TestCopyFilePreservesModTime(t *testing.T) {
})
}
}
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
const content = "complete file contents"
src := struct{ io.Reader }{bytes.NewBufferString(content)}
dst := &copyFileRangeRejectingWriter{}
if err := copyFileContents(dst, src); err != nil {
t.Fatal(err)
}
if got := dst.String(); got != content {
t.Fatalf("copied contents = %q, want %q", got, content)
}
if dst.readFromCalls != 1 {
t.Fatalf("ReadFrom calls = %d, want 1", dst.readFromCalls)
}
}
@@ -0,0 +1,130 @@
//go:build linux
package fileutils
import (
"fmt"
"os"
"path/filepath"
"syscall"
"golang.org/x/sys/unix"
)
// GetAggregatedPartitionUsage returns total and used bytes summed across distinct
// filesystems mounted at or under root (deduped by major:minor from mountinfo).
// Falls back to a single-path probe when mountinfo cannot be read.
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
if root == "" {
return 0, 0, fmt.Errorf("empty path")
}
root = filepath.Clean(root)
paths, parseErr := distinctMountPathsUnder(root)
if parseErr != nil || len(paths) == 0 {
return singlePathPartitionUsage(root)
}
var firstErr error
for _, path := range paths {
t, u, stErr := partitionUsageAt(path)
if stErr != nil {
if firstErr == nil {
firstErr = stErr
}
continue
}
total += t
used += u
}
if total == 0 && used == 0 {
if firstErr != nil {
return 0, 0, firstErr
}
return singlePathPartitionUsage(root)
}
return total, used, nil
}
func singlePathPartitionUsage(root string) (total, used uint64, err error) {
total, err = GetPartitionSize(root)
if err != nil {
return 0, 0, err
}
used, err = GetPartitionUsed(root)
if err != nil {
return 0, 0, err
}
return total, used, nil
}
func distinctMountPathsUnder(root string) ([]string, error) {
data, err := readMountinfo()
if err != nil {
return nil, err
}
paths, err := distinctMountPathsFromMountinfo(data, root)
if err != nil {
return nil, err
}
// If mountinfo had no covering mount for root, ensure root is probed.
if len(paths) == 0 {
return []string{root}, nil
}
hasRoot := false
for _, p := range paths {
if p == root {
hasRoot = true
break
}
}
if !hasRoot {
if dev, stErr := deviceIDFromPath(root); stErr == nil {
// Avoid duplicating a device already selected under another path.
already := false
for _, p := range paths {
if d, e := deviceIDFromPath(p); e == nil && d == dev {
already = true
break
}
}
if !already {
paths = append(paths, root)
}
}
}
return paths, nil
}
func readMountinfo() (string, error) {
for _, path := range []string{"/proc/self/mountinfo", "/proc/1/mountinfo"} {
b, err := os.ReadFile(path)
if err == nil {
return string(b), nil
}
}
return "", fmt.Errorf("mountinfo not available")
}
func deviceIDFromPath(path string) (string, error) {
var st unix.Stat_t
if err := unix.Stat(path, &st); err != nil {
return "", err
}
return fmt.Sprintf("%d:%d", unix.Major(uint64(st.Dev)), unix.Minor(uint64(st.Dev))), nil
}
func partitionUsageAt(path string) (total, used uint64, err error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, 0, err
}
bsize := blockSize(&stat)
total = uint64(stat.Blocks) * bsize
free := uint64(stat.Bavail) * bsize
if total >= free {
used = total - free
}
return total, used, nil
}
@@ -0,0 +1,111 @@
//go:build linux
package fileutils
import (
"bufio"
"path"
"strconv"
"strings"
)
type mountEntry struct {
deviceID string
mountpoint string
}
// distinctMountPathsFromMountinfo selects one path per distinct filesystem that
// contributes capacity for root: the FS that owns root, plus nested mounts under
// root. Deduped by major:minor from mountinfo.
func distinctMountPathsFromMountinfo(mountinfo string, root string) ([]string, error) {
root = path.Clean(root)
entries, err := parseAllMounts(mountinfo)
if err != nil {
return nil, err
}
seen := make(map[string]string)
var covering *mountEntry
for i := range entries {
e := &entries[i]
mp := e.mountpoint
if mp == root || strings.HasPrefix(root, mp+"/") || mp == "/" {
if covering == nil || len(mp) > len(covering.mountpoint) {
covering = e
}
}
}
if covering != nil {
seen[covering.deviceID] = root
}
for _, e := range entries {
mp := e.mountpoint
if mp != root && !strings.HasPrefix(mp, root+"/") {
continue
}
if _, ok := seen[e.deviceID]; ok {
continue
}
seen[e.deviceID] = mp
}
out := make([]string, 0, len(seen))
uniq := make(map[string]struct{}, len(seen))
for _, p := range seen {
if _, ok := uniq[p]; ok {
continue
}
uniq[p] = struct{}{}
out = append(out, p)
}
return out, nil
}
func parseAllMounts(mountinfo string) ([]mountEntry, error) {
var out []mountEntry
scanner := bufio.NewScanner(strings.NewReader(mountinfo))
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
entry, ok := parseMountinfoLine(scanner.Text())
if !ok {
continue
}
entry.mountpoint = path.Clean(entry.mountpoint)
out = append(out, entry)
}
return out, scanner.Err()
}
func parseMountinfoLine(line string) (mountEntry, bool) {
parts := strings.SplitN(line, " - ", 2)
if len(parts) != 2 {
return mountEntry{}, false
}
fields := strings.Fields(parts[0])
if len(fields) < 5 {
return mountEntry{}, false
}
return mountEntry{
deviceID: fields[2],
mountpoint: unescapeFstab(fields[4]),
}, true
}
func unescapeFstab(path string) string {
var b strings.Builder
b.Grow(len(path))
for i := 0; i < len(path); i++ {
if path[i] == '\\' && i+3 < len(path) {
if n, err := strconv.ParseUint(path[i+1:i+4], 8, 8); err == nil {
b.WriteByte(byte(n))
i += 3
continue
}
}
b.WriteByte(path[i])
}
return b.String()
}
@@ -0,0 +1,113 @@
//go:build linux
package fileutils
import (
"sort"
"testing"
)
func TestDistinctMountPathsNestedDisk(t *testing.T) {
// /srv lives on rootfs 8:1; /srv/data is a separate 2TB disk 8:16.
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"45 22 8:16 /data /srv/data rw - btrfs /dev/sdb1 rw\n" +
"50 22 0:47 / /proc rw - proc proc rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
want := []string{"/srv", "/srv/data"}
if len(paths) != len(want) {
t.Fatalf("paths = %v, want %v", paths, want)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("paths = %v, want %v", paths, want)
}
}
}
func TestDistinctMountPathsOvermountSamePathDeduped(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 8:2 / /srv rw - ext4 /dev/sda2 rw\n" +
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
"46 45 0:47 / /srv/data rw - tmpfs tmpfs rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
want := []string{"/srv", "/srv/data"}
if len(paths) != len(want) {
t.Fatalf("paths = %v, want %v (no duplicate probe paths)", paths, want)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("paths = %v, want %v", paths, want)
}
}
}
func TestDistinctMountPathsBindSameDeviceNotDuplicated(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"45 22 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n" +
"46 22 8:16 / /srv/data/bind rw,bind - btrfs /dev/sdb1 rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
if len(paths) != 2 {
t.Fatalf("expected 2 distinct devices, got %v", paths)
}
}
func TestDistinctMountPathsSourceIsOwnMount(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"40 22 8:2 / /srv rw - ext4 /dev/sda2 rw\n" +
"45 40 8:16 / /srv/data rw - btrfs /dev/sdb1 rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
sort.Strings(paths)
want := []string{"/srv", "/srv/data"}
if len(paths) != 2 || paths[0] != want[0] || paths[1] != want[1] {
t.Fatalf("paths = %v, want %v", paths, want)
}
}
func TestDistinctMountPathsIgnoresUnrelated(t *testing.T) {
mountinfo := "" +
"22 1 8:1 / / rw - ext4 /dev/sda1 rw\n" +
"30 22 8:3 / /home rw - ext4 /dev/sda3 rw\n"
paths, err := distinctMountPathsFromMountinfo(mountinfo, "/srv")
if err != nil {
t.Fatal(err)
}
if len(paths) != 1 || paths[0] != "/srv" {
t.Fatalf("paths = %v, want [/srv]", paths)
}
}
func TestParseMountinfoEscapedSpace(t *testing.T) {
entry, ok := parseMountinfoLine(`36 35 98:0 /mnt1 /mnt\040two rw - ext3 /dev/root rw`)
if !ok {
t.Fatal("expected parse ok")
}
if entry.mountpoint != "/mnt two" {
t.Fatalf("mountpoint = %q, want /mnt two", entry.mountpoint)
}
if entry.deviceID != "98:0" {
t.Fatalf("deviceID = %q, want 98:0", entry.deviceID)
}
}
@@ -0,0 +1,17 @@
//go:build !linux
package fileutils
// GetAggregatedPartitionUsage returns total and used bytes for the filesystem
// that owns root. Non-Linux builds use a single statfs/GetDiskFreeSpaceEx path.
func GetAggregatedPartitionUsage(root string) (total, used uint64, err error) {
total, err = GetPartitionSize(root)
if err != nil {
return 0, 0, err
}
used, err = GetPartitionUsed(root)
if err != nil {
return 0, 0, err
}
return total, used, nil
}
@@ -0,0 +1,47 @@
//go:build linux
package fileutils
import (
"syscall"
)
func blockSize(stat *syscall.Statfs_t) uint64 {
if stat.Frsize > 0 {
return uint64(stat.Frsize)
}
return uint64(stat.Bsize)
}
// GetPartitionSize returns the filesystem size for Linux systems
func GetPartitionSize(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
return uint64(stat.Blocks) * blockSize(&stat), nil
}
// GetFreeSpace returns the available free space for Linux systems
func GetFreeSpace(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
return uint64(stat.Bavail) * blockSize(&stat), nil
}
// GetPartitionUsed returns the used space for Linux systems (total - free)
func GetPartitionUsed(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
bsize := blockSize(&stat)
total := uint64(stat.Blocks) * bsize
free := uint64(stat.Bavail) * bsize
return total - free, nil
}
@@ -0,0 +1,48 @@
//go:build linux
package fileutils
import (
"syscall"
"testing"
)
func TestBlockSizePrefersFrsize(t *testing.T) {
// virtiofs (Docker Desktop for Mac) reports Bsize as the 1 MiB optimal
// transfer size while Blocks are still counted in the 4 KiB fragment unit.
stat := &syscall.Statfs_t{
Bsize: 1048576,
Frsize: 4096,
Blocks: 1465121792,
Bavail: 1054717543,
}
got := blockSize(stat)
if got != 4096 {
t.Fatalf("blockSize = %d, want 4096 (Frsize)", got)
}
total := uint64(stat.Blocks) * blockSize(stat)
const wantTotal = 1465121792 * 4096 // ~5.5 TiB, matches df
if total != wantTotal {
t.Fatalf("total = %d bytes, want %d (~5.5 TiB)", total, uint64(wantTotal))
}
// Sanity: the old Bsize-based math overstates by exactly 256x.
if inflated := uint64(stat.Blocks) * uint64(stat.Bsize); inflated != total*256 {
t.Fatalf("expected 256x inflation from Bsize, got %d vs %d", inflated, total)
}
}
func TestBlockSizeFallsBackToBsize(t *testing.T) {
// Normal filesystems report Frsize == Bsize (or leave Frsize unset on
// exotic drivers); either way the accounting unit is recovered.
stat := &syscall.Statfs_t{Bsize: 4096, Frsize: 4096}
if got := blockSize(stat); got != 4096 {
t.Fatalf("blockSize = %d, want 4096", got)
}
zero := &syscall.Statfs_t{Bsize: 4096, Frsize: 0}
if got := blockSize(zero); got != 4096 {
t.Fatalf("blockSize with Frsize=0 = %d, want 4096 (Bsize fallback)", got)
}
}
@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !linux
package fileutils
+245
View File
@@ -0,0 +1,245 @@
package analytics
import (
"bufio"
"os"
"path/filepath"
"runtime"
"strings"
)
const (
hostPlatformUnknown = "unknown"
hostPlatformGenericLinux = "generic-linux"
hostPlatformUnraid = "unraid"
hostPlatformTrueNAS = "truenas"
hostPlatformSynology = "synology"
hostPlatformProxmox = "proxmox"
hostPlatformWindows = "windows"
hostPlatformMacOS = "macos"
)
// Roots where a Docker image may mount the host filesystem.
var hostRootPrefixes = []string{"/host", "/rootfs", "/mnt/host", ""}
// nativeHostRootPrefix is the scan root for non-container host detection. Tests may override.
var nativeHostRootPrefix = ""
func detectHostPlatform(containerRuntime string) string {
switch runtime.GOOS {
case "windows":
return hostPlatformWindows
case "darwin":
return hostPlatformMacOS
case "linux":
return detectLinuxHostPlatform(containerRuntime)
case "freebsd":
return detectFreeBSDHostPlatform(containerRuntime)
default:
return hostPlatformUnknown
}
}
func hostScanPrefixes(containerRuntime string) ([]string, bool) {
inContainer := containerRuntime == "docker" || containerRuntime == "kubernetes"
if !inContainer {
return []string{nativeHostRootPrefix}, false
}
return hostRootPrefixes, true
}
func detectLinuxHostPlatform(containerRuntime string) string {
prefixes, inContainer := hostScanPrefixes(containerRuntime)
for _, prefix := range prefixes {
if platform := detectLinuxMarkers(prefix); platform != "" {
return platform
}
}
for _, prefix := range prefixes {
if platform := classifyOSRelease(readOSRelease(prefix)); platform != "" {
if platform != hostPlatformGenericLinux || !inContainer || prefix != "" {
return platform
}
}
}
if inContainer {
return hostPlatformUnknown
}
if classifyOSRelease(readOSRelease(nativeHostRootPrefix)) == hostPlatformGenericLinux {
return hostPlatformGenericLinux
}
return hostPlatformUnknown
}
func detectFreeBSDHostPlatform(containerRuntime string) string {
prefixes, inContainer := hostScanPrefixes(containerRuntime)
for _, prefix := range prefixes {
if platform := classifyOSRelease(readOSRelease(prefix)); platform != "" {
if platform != hostPlatformGenericLinux {
return platform
}
}
if platform := classifyFreeBSDVersion(readFile(hostPath(prefix, "etc/version"))); platform != "" {
return platform
}
}
if inContainer {
return hostPlatformUnknown
}
return hostPlatformUnknown
}
func classifyFreeBSDVersion(content string) string {
lower := strings.ToLower(strings.TrimSpace(content))
switch {
case strings.Contains(lower, "truenas"), strings.Contains(lower, "freenas"):
return hostPlatformTrueNAS
default:
return ""
}
}
func detectLinuxMarkers(rootPrefix string) string {
markers := []struct {
path string
platform string
}{
{"etc/unraid-version", hostPlatformUnraid},
{"etc/synoinfo.conf", hostPlatformSynology},
{"etc/pve/.version", hostPlatformProxmox},
}
for _, marker := range markers {
if fileExists(hostPath(rootPrefix, marker.path)) {
return marker.platform
}
}
return ""
}
func readOSRelease(rootPrefix string) string {
return readFile(hostPath(rootPrefix, "etc/os-release"))
}
func hostPath(rootPrefix, rel string) string {
if rootPrefix == "" {
return filepath.Join(string(os.PathSeparator), rel)
}
return filepath.Join(rootPrefix, rel)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func readFile(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(data)
}
func parseOSRelease(content string) map[string]string {
values := make(map[string]string)
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"`)
}
return values
}
func classifyOSRelease(content string) string {
if strings.TrimSpace(content) == "" {
return ""
}
vars := parseOSRelease(content)
id := strings.ToLower(vars["ID"])
idLike := strings.ToLower(vars["ID_LIKE"])
pretty := strings.ToLower(vars["PRETTY_NAME"])
name := strings.ToLower(vars["NAME"])
combined := strings.Join([]string{id, idLike, pretty, name}, " ")
switch {
case strings.Contains(combined, "unraid"):
return hostPlatformUnraid
case strings.Contains(combined, "truenas"), strings.Contains(combined, "freenas"):
return hostPlatformTrueNAS
case strings.Contains(combined, "synology"):
return hostPlatformSynology
case strings.Contains(combined, "proxmox"):
return hostPlatformProxmox
case id != "":
return hostPlatformGenericLinux
default:
return ""
}
}
const (
deploymentRuntimeKubernetes = "kubernetes"
deploymentRuntimeDocker = "docker"
deploymentRuntimeLinux = "linux"
deploymentRuntimeWindows = "windows"
deploymentRuntimeMacOS = "macos"
deploymentRuntimeUnknown = "unknown"
)
// detectDeploymentRuntime reports how the instance is deployed: detected NAS/hypervisor
// product when possible, otherwise kubernetes, docker, or native OS (linux, windows, macos).
func detectDeploymentRuntime() string {
return deploymentRuntimeFrom(detectRuntime())
}
func deploymentRuntimeFrom(containerRuntime string) string {
platform := detectHostPlatform(containerRuntime)
if isProductDeploymentRuntime(platform) {
return platform
}
switch containerRuntime {
case "kubernetes":
return deploymentRuntimeKubernetes
case "docker":
return deploymentRuntimeDocker
}
switch runtime.GOOS {
case "windows":
return deploymentRuntimeWindows
case "darwin":
return deploymentRuntimeMacOS
case "linux":
return deploymentRuntimeLinux
default:
return deploymentRuntimeUnknown
}
}
func isProductDeploymentRuntime(platform string) bool {
switch platform {
case hostPlatformUnraid, hostPlatformTrueNAS, hostPlatformSynology, hostPlatformProxmox,
hostPlatformWindows, hostPlatformMacOS:
return true
default:
return false
}
}
@@ -0,0 +1,207 @@
package analytics
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestClassifyOSRelease(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{
name: "unraid",
content: `NAME="Unraid OS"
ID=unraid
ID_LIKE="debian"`,
want: hostPlatformUnraid,
},
{
name: "truenas scale",
content: `NAME="TrueNAS-SCALE"
ID=truenas-scale
ID_LIKE=debian`,
want: hostPlatformTrueNAS,
},
{
name: "freenas",
content: `PRETTY_NAME="FreeNAS-11.3-U5"`,
want: hostPlatformTrueNAS,
},
{
name: "synology",
content: `NAME="Synology"
ID=synology`,
want: hostPlatformSynology,
},
{
name: "proxmox",
content: `PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
ID=debian
NAME="Proxmox VE"`,
want: hostPlatformProxmox,
},
{
name: "generic ubuntu",
content: `NAME="Ubuntu"
ID=ubuntu
ID_LIKE=debian`,
want: hostPlatformGenericLinux,
},
{
name: "empty",
content: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyOSRelease(tt.content); got != tt.want {
t.Fatalf("classifyOSRelease() = %q, want %q", got, tt.want)
}
})
}
}
func withHostDetectionRoots(t *testing.T, roots []string, nativeRoot string) {
t.Cleanup(func() {
hostRootPrefixes = []string{"/host", "/rootfs", "/mnt/host", ""}
nativeHostRootPrefix = ""
})
hostRootPrefixes = roots
nativeHostRootPrefix = nativeRoot
}
func writeFixtureFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
}
func TestDetectLinuxHostPlatformMarkerPrecedence(t *testing.T) {
root := t.TempDir()
hostMount := filepath.Join(root, "host")
writeFixtureFile(t, filepath.Join(hostMount, "etc", "unraid-version"), "6.12.0")
writeFixtureFile(t, filepath.Join(hostMount, "etc", "os-release"), `NAME="Ubuntu"
ID=ubuntu`)
withHostDetectionRoots(t, []string{hostMount}, "")
if got := detectLinuxHostPlatform("docker"); got != hostPlatformUnraid {
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformUnraid)
}
}
func TestDetectLinuxHostPlatformMountedOSRelease(t *testing.T) {
root := t.TempDir()
hostMount := filepath.Join(root, "host")
writeFixtureFile(t, filepath.Join(hostMount, "etc", "os-release"), `NAME="Synology"
ID=synology`)
withHostDetectionRoots(t, []string{hostMount}, "")
if got := detectLinuxHostPlatform("kubernetes"); got != hostPlatformSynology {
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformSynology)
}
}
func TestDetectLinuxHostPlatformContainerUnknownFallback(t *testing.T) {
root := t.TempDir()
withHostDetectionRoots(t, []string{filepath.Join(root, "host")}, "")
if got := detectLinuxHostPlatform("docker"); got != hostPlatformUnknown {
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformUnknown)
}
}
func TestDetectLinuxHostPlatformNativeSkipsHostMounts(t *testing.T) {
root := t.TempDir()
hostOnly := filepath.Join(root, "host")
nativeRoot := filepath.Join(root, "native")
writeFixtureFile(t, filepath.Join(hostOnly, "etc", "unraid-version"), "6.12.0")
writeFixtureFile(t, filepath.Join(nativeRoot, "etc", "os-release"), `NAME="Ubuntu"
ID=ubuntu`)
withHostDetectionRoots(t, []string{hostOnly}, nativeRoot)
if got := detectLinuxHostPlatform("native"); got != hostPlatformGenericLinux {
t.Fatalf("detectLinuxHostPlatform() = %q, want %q", got, hostPlatformGenericLinux)
}
}
func TestDetectFreeBSDHostPlatformTrueNASVersion(t *testing.T) {
root := t.TempDir()
hostMount := filepath.Join(root, "host")
writeFixtureFile(t, filepath.Join(hostMount, "etc", "version"), "TrueNAS-13.0-U6.1")
withHostDetectionRoots(t, []string{hostMount}, "")
if got := detectFreeBSDHostPlatform("docker"); got != hostPlatformTrueNAS {
t.Fatalf("detectFreeBSDHostPlatform() = %q, want %q", got, hostPlatformTrueNAS)
}
}
func TestClassifyFreeBSDVersion(t *testing.T) {
tests := map[string]string{
"TrueNAS-13.0-U6.1": hostPlatformTrueNAS,
"FreeNAS-11.3-U5": hostPlatformTrueNAS,
"13.2-RELEASE": "",
}
for input, want := range tests {
if got := classifyFreeBSDVersion(input); got != want {
t.Fatalf("classifyFreeBSDVersion(%q) = %q, want %q", input, got, want)
}
}
}
func TestDeploymentRuntimeFrom(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("deployment runtime integration requires GOOS linux")
}
t.Run("platform beats docker", func(t *testing.T) {
root := t.TempDir()
hostMount := filepath.Join(root, "host")
writeFixtureFile(t, filepath.Join(hostMount, "etc", "unraid-version"), "6.12.0")
withHostDetectionRoots(t, []string{hostMount}, "")
if got := deploymentRuntimeFrom("docker"); got != hostPlatformUnraid {
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, hostPlatformUnraid)
}
})
t.Run("docker without host visibility", func(t *testing.T) {
emptyHost := filepath.Join(t.TempDir(), "host")
withHostDetectionRoots(t, []string{emptyHost}, "")
if got := deploymentRuntimeFrom("docker"); got != deploymentRuntimeDocker {
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, deploymentRuntimeDocker)
}
})
t.Run("kubernetes", func(t *testing.T) {
withHostDetectionRoots(t, []string{filepath.Join(t.TempDir(), "host")}, "")
if got := deploymentRuntimeFrom("kubernetes"); got != deploymentRuntimeKubernetes {
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, deploymentRuntimeKubernetes)
}
})
t.Run("native linux", func(t *testing.T) {
root := t.TempDir()
nativeRoot := filepath.Join(root, "native")
writeFixtureFile(t, filepath.Join(nativeRoot, "etc", "os-release"), `NAME="Ubuntu"
ID=ubuntu`)
withHostDetectionRoots(t, []string{filepath.Join(root, "unused-host")}, nativeRoot)
if got := deploymentRuntimeFrom("native"); got != deploymentRuntimeLinux {
t.Fatalf("deploymentRuntimeFrom() = %q, want %q", got, deploymentRuntimeLinux)
}
})
}
+5 -3
View File
@@ -52,7 +52,9 @@ type systemPayload struct {
CPUCores int `json:"cpu_cores"`
MemoryRSSMB int `json:"memory_rss_mb"`
MemoryTotalMB int `json:"memory_total_mb"`
Runtime string `json:"runtime"`
// Runtime is deployment context: unraid, synology, truenas, proxmox, kubernetes,
// docker, linux, windows, macos, or unknown.
Runtime string `json:"runtime"`
}
type countsPayload struct {
@@ -178,7 +180,7 @@ func collectPayload(versionLabel string) (snapshotPayload, error) {
CPUCores: runtime.NumCPU(),
MemoryRSSMB: memoryRSSMB(),
MemoryTotalMB: systemTotalMemoryMB(),
Runtime: detectRuntime(),
Runtime: detectDeploymentRuntime(),
},
Counts: countsPayload{
SourcesEnabled: sourceCounts.enabled,
@@ -308,7 +310,7 @@ func mapSystemOS(goos string) string {
func mapSystemArch(goarch string) string {
switch goarch {
case "amd64", "arm64", "386", "arm":
case "amd64", "arm64", "386", "arm", "riscv64":
return goarch
default:
return "other"
+124
View File
@@ -0,0 +1,124 @@
package auth
import (
"bytes"
"encoding/base64"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
const base64URLAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
func decodeJWTBase64URL(s string) ([]byte, error) {
switch len(s) % 4 {
case 2:
s += "=="
case 3:
s += "="
}
return base64.URLEncoding.DecodeString(s)
}
// equivalentSpelling returns a different compact JWT string with the same decoded signature bytes.
func equivalentSpelling(token string) string {
parts := strings.Split(token, ".")
if len(parts) != 3 {
panic("equivalentSpelling: invalid JWT")
}
header, payload, signature := parts[0], parts[1], parts[2]
orig, err := decodeJWTBase64URL(signature)
if err != nil {
panic("equivalentSpelling: decode canonical signature: " + err.Error())
}
last := signature[len(signature)-1]
for _, c := range base64URLAlphabet {
if byte(c) == last {
continue
}
trial := signature[:len(signature)-1] + string(c)
dec, err := decodeJWTBase64URL(trial)
if err != nil || !bytes.Equal(dec, orig) {
continue
}
respelled := header + "." + payload + "." + trial
if respelled != token {
return respelled
}
}
panic("equivalentSpelling: no alternate spelling found")
}
func invalidSignatureSpelling(token string) string {
parts := strings.Split(token, ".")
header, payload, signature := parts[0], parts[1], parts[2]
idx := strings.IndexByte(base64URLAlphabet, signature[len(signature)-1])
replacement := base64URLAlphabet[((idx&0b110000)+16)%64]
return header + "." + payload + "." + signature[:len(signature)-1] + string(replacement)
}
func parseSessionToken(tokenString string) error {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(settings.Config.Auth.Key), nil
}
var tk users.AuthToken
_, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
return err
}
func TestDecodeStrictRejectsEquivalentSpelling(t *testing.T) {
origKey := settings.Config.Auth.Key
origStrict := jwt.DecodeStrict
settings.Config.Auth.Key = "test-signing-key-strict-decode"
jwt.DecodeStrict = true
t.Cleanup(func() {
settings.Config.Auth.Key = origKey
jwt.DecodeStrict = origStrict
})
user := &users.User{ID: 1, FrontendUser: users.FrontendUser{Username: "alice"}}
canonical, _, err := MakeSignedTokenAPI(user, "WEB_TOKEN_test", time.Hour, user.Permissions, false)
if err != nil {
t.Fatalf("MakeSignedTokenAPI: %v", err)
}
if err := parseSessionToken(canonical); err != nil {
t.Fatalf("canonical token should parse under strict mode: %v", err)
}
respelled := equivalentSpelling(canonical)
if err := parseSessionToken(respelled); err == nil {
t.Fatal("equivalent spelling should be rejected when jwt.DecodeStrict is true")
}
tampered := invalidSignatureSpelling(canonical)
if err := parseSessionToken(tampered); err == nil {
t.Fatal("tampered signature should be rejected")
}
}
func TestEquivalentSpellingParsesWithoutStrictMode(t *testing.T) {
origKey := settings.Config.Auth.Key
origStrict := jwt.DecodeStrict
settings.Config.Auth.Key = "test-signing-key-strict-decode"
jwt.DecodeStrict = false
t.Cleanup(func() {
settings.Config.Auth.Key = origKey
jwt.DecodeStrict = origStrict
})
user := &users.User{ID: 1, FrontendUser: users.FrontendUser{Username: "alice"}}
canonical, _, err := MakeSignedTokenAPI(user, "WEB_TOKEN_test", time.Hour, user.Permissions, false)
if err != nil {
t.Fatalf("MakeSignedTokenAPI: %v", err)
}
respelled := equivalentSpelling(canonical)
if err := parseSessionToken(respelled); err != nil {
t.Fatalf("equivalent spelling should parse without strict mode (documents pre-fix behavior): %v", err)
}
}
+2
View File
@@ -9,6 +9,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/ports"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Service performs authentication using injected user reads. It never persists users.
@@ -72,6 +73,7 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
}
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Debugf("login rejected: user %q attempted password login but account loginMethod is %q", username, user.LoginMethod)
return nil, errors.ErrWrongLoginMethod
}
return &user, nil
+54 -22
View File
@@ -89,11 +89,39 @@ func (s *Storage) SetSQLStore(sqlStore SQLPersister) {
s.sqlStore = sqlStore
}
// Flush is a no-op; access rule updates already write through sqlStore when configured.
// Flush is a no-op; access rule and group updates already write through sqlStore when configured.
func (s *Storage) Flush() error {
return nil
}
// persistGroupSQLNL upserts or deletes one groups row to match in-memory state.
// Caller must hold s.mux. If the group is absent from s.Groups, it is deleted from SQL.
func (s *Storage) persistGroupSQLNL(groupname string) {
if s.sqlStore == nil {
return
}
members, ok := s.Groups[groupname]
if !ok {
if err := s.sqlStore.DeleteGroup(groupname); err != nil {
logger.Errorf("failed to delete group %q from sql: %v", groupname, err)
}
return
}
if err := s.sqlStore.SaveGroup(groupname, members); err != nil {
logger.Errorf("failed to save group %q: %v", groupname, err)
}
}
// ensureGroupExistsNL creates an empty in-memory group if missing and write-through persists it.
// Caller must hold s.mux.
func (s *Storage) ensureGroupExistsNL(groupname string) {
if _, ok := s.Groups[groupname]; ok {
return
}
s.Groups[groupname] = make(StringSet)
s.persistGroupSQLNL(groupname)
}
// NewStorage creates a new Storage instance.
func NewStorage(usersStore *users.Storage) *Storage {
var s = &Storage{
@@ -235,13 +263,11 @@ func (s *Storage) AllowUser(sourcePath string, indexPath utils.IndexPath, userna
}
// DenyGroup adds a group to the deny list for a given source and index path.
// Unknown groups are auto-created (empty) so admins can configure rules before first SSO sync.
func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
_, ok := s.Groups[groupname]
if !ok {
return fmt.Errorf("group '%s' does not exist", groupname)
}
s.ensureGroupExistsNL(groupname)
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Groups[groupname]; ok {
return errors.ErrExist
@@ -253,13 +279,11 @@ func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupn
}
// AllowGroup adds a group to the allow list for a given source and index path.
// Unknown groups are auto-created (empty) so admins can configure rules before first SSO sync.
func (s *Storage) AllowGroup(sourcePath string, indexPath utils.IndexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
_, ok := s.Groups[groupname]
if !ok {
return fmt.Errorf("group '%s' does not exist", groupname)
}
s.ensureGroupExistsNL(groupname)
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Groups[groupname]; ok {
return errors.ErrExist
@@ -516,6 +540,8 @@ func (s *Storage) AddUserToGroup(group, username string) error {
return nil
}
s.Groups[group][username] = struct{}{}
s.persistGroupSQLNL(group)
s.clearAllCaches()
return nil
}
@@ -546,26 +572,30 @@ func (s *Storage) GetUserGroups(username string) []string {
// SyncUserGroups updates a user's group memberships.
// It removes the user from groups not in the new list and adds them to new ones.
// Changes are write-through to SQL when a sqlStore is configured.
func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
affected := make(map[string]struct{})
// Create a set of new groups for efficient lookup
newGroupsSet := make(StringSet, len(newGroups))
for _, g := range newGroups {
if g == "" {
continue
}
newGroupsSet[g] = struct{}{}
}
// Iterate over all existing groups to find the user's current memberships
for group, users := range s.Groups {
_, userIsInGroup := users[username]
for group, members := range s.Groups {
_, userIsInGroup := members[username]
_, groupIsInNewSet := newGroupsSet[group]
// If user is in a group that is not in their new set of groups, remove them.
if userIsInGroup && !groupIsInNewSet {
delete(s.Groups[group], username)
changed = true
affected[group] = struct{}{}
}
}
@@ -576,11 +606,14 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
}
if _, ok := s.Groups[group][username]; !ok {
s.Groups[group][username] = struct{}{}
changed = true
affected[group] = struct{}{}
}
}
if changed {
return nil
for group := range affected {
s.persistGroupSQLNL(group)
}
if len(affected) > 0 {
s.clearAllCaches()
}
return nil
}
@@ -589,17 +622,16 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
func (s *Storage) RemoveUserFromGroup(group, username string) error {
s.mux.Lock()
defer s.mux.Unlock()
users, ok := s.Groups[group]
members, ok := s.Groups[group]
if !ok {
return nil
}
if _, ok := users[username]; !ok {
if _, ok := members[username]; !ok {
return nil
}
delete(users, username)
if len(s.Groups[group]) == 0 {
delete(s.Groups, group)
}
delete(members, username)
s.persistGroupSQLNL(group)
s.clearAllCaches()
return nil
}
+146 -7
View File
@@ -6,12 +6,12 @@ import (
"strings"
"testing"
"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/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/sqldb"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func idxPath(s string) utils.IndexPath {
@@ -62,7 +62,13 @@ func (s *sqlStoreAdapter) DeleteByID(id uint64) error {
}
func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) {
// Create a temporary directory for the test database
t.Helper()
accessStore, userStore, _ := createTestStorageWithSQL(t)
return accessStore, userStore
}
func createTestStorageWithSQL(t *testing.T) (*access.Storage, *users.Storage, *sqldb.SQLStore) {
t.Helper()
tempDir, err := os.MkdirTemp("", "access_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -71,7 +77,6 @@ func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) {
os.RemoveAll(tempDir)
})
// Create an in-memory SQLite database for tests
dbPath := filepath.Join(tempDir, "test.db")
sqlStore, _, err := sqldb.NewSQLStore(dbPath)
if err != nil {
@@ -81,10 +86,11 @@ func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) {
sqlStore.Close()
})
// Wrap SQLStore with adapter
adapter := &sqlStoreAdapter{store: sqlStore}
userStore := users.NewStorage(adapter)
return access.NewStorage(userStore), userStore
accessStore := access.NewStorage(userStore)
accessStore.SetSQLStore(sqlStore)
return accessStore, userStore, sqlStore
}
func createTestUser(t *testing.T, userStore *users.Storage, username string) {
@@ -209,6 +215,27 @@ func TestPermitted_NoRule(t *testing.T) {
}
}
func TestPermitted_trailingSlashEquivalence(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "alice")
if err := s.DenyUser("mnt/storage", idxPath("/secret/"), "alice"); err != nil {
t.Fatalf("DenyUser failed: %v", err)
}
withSlash := s.Permitted("mnt/storage", idxPath("/secret/"), "alice")
withoutSlash := s.Permitted("mnt/storage", idxPath("/secret"), "alice")
if withSlash != withoutSlash {
t.Errorf("trailing slash mismatch: with=%v without=%v", withSlash, withoutSlash)
}
if withSlash {
t.Error("alice should be denied for /secret/ after DenyUser")
}
if withoutSlash {
t.Error("alice should be denied for /secret after DenyUser")
}
}
func TestPermitted_CombinedRules(t *testing.T) {
setupTestSources()
s, userStore := createTestStorage(t)
@@ -1712,3 +1739,115 @@ func TestRemoveUserCascade_MixedUsers(t *testing.T) {
t.Log("✓ Cascade delete only affects the specified user")
}
func TestSyncUserGroups_WriteThroughAndReload(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SyncUserGroups("alice", []string{"acme", "staff"}); err != nil {
t.Fatalf("SyncUserGroups: %v", err)
}
groups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups: %v", err)
}
if _, ok := groups["acme"]["alice"]; !ok {
t.Fatalf("expected alice in acme in SQL, got %#v", groups)
}
if _, ok := groups["staff"]["alice"]; !ok {
t.Fatalf("expected alice in staff in SQL, got %#v", groups)
}
// Simulate restart: new Storage loaded from SQL
reloaded := access.NewStorage(nil)
reloaded.Groups = groups
if got := reloaded.GetUserGroups("alice"); len(got) != 2 {
t.Fatalf("reloaded groups for alice: %v", got)
}
// Sync to a single group and ensure SQL drops the old membership
if err = s.SyncUserGroups("alice", []string{"acme"}); err != nil {
t.Fatalf("SyncUserGroups update: %v", err)
}
groups, err = sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups after update: %v", err)
}
if _, ok := groups["acme"]["alice"]; !ok {
t.Fatal("alice should remain in acme")
}
if _, ok := groups["staff"]["alice"]; ok {
t.Fatal("alice should no longer be in staff in SQL")
}
}
func TestAllowGroup_AutoCreatesMissingGroup(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup should auto-create group: %v", err)
}
groups := s.GetAllGroups()
found := false
for _, g := range groups {
if g == "acme" {
found = true
}
}
if !found {
t.Fatal("expected acme group in memory")
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups: %v", err)
}
if _, ok := sqlGroups["acme"]; !ok {
t.Fatal("expected acme group persisted to SQL")
}
// After sync, user membership should permit access under denyByDefault
settings.Config.Server.SourceMap["mnt/storage"].Config.DenyByDefault = true
t.Cleanup(func() {
settings.Config.Server.SourceMap["mnt/storage"].Config.DenyByDefault = false
})
if s.Permitted("mnt/storage", idxPath("/tenant"), "alice") {
t.Fatal("alice should be denied before group membership")
}
if err := s.SyncUserGroups("alice", []string{"acme"}); err != nil {
t.Fatalf("SyncUserGroups: %v", err)
}
if !s.Permitted("mnt/storage", idxPath("/tenant"), "alice") {
t.Fatal("alice should be permitted after JWT-style group sync")
}
}
func TestAddUserToGroup_WriteThrough(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.AddUserToGroup("editors", "bob"); err != nil {
t.Fatalf("AddUserToGroup: %v", err)
}
groups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups: %v", err)
}
if _, ok := groups["editors"]["bob"]; !ok {
t.Fatalf("expected bob in editors in SQL, got %#v", groups)
}
if err = s.RemoveUserFromGroup("editors", "bob"); err != nil {
t.Fatalf("RemoveUserFromGroup: %v", err)
}
groups, err = sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups after remove: %v", err)
}
if _, ok := groups["editors"]; !ok {
t.Fatal("expected editors group to remain after last member removed")
}
if _, still := groups["editors"]["bob"]; still {
t.Fatal("bob should be removed from editors in SQL")
}
}
+13 -11
View File
@@ -3,7 +3,6 @@ package share
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
@@ -62,31 +61,34 @@ func prepForFrontendOne(link *Share, viewer *users.User, r *http.Request, public
}
out.SingleFileShare = snap.IsSingleFileShare()
if r != nil && publicHost != "" {
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, snap.Token)
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, snap.Token)
out.DownloadURL = PublicShareURL(publicHost, publicScheme, out.Hash, true, out.HasPassword)
out.ShareURL = PublicShareURL(publicHost, publicScheme, out.Hash, false, out.HasPassword)
}
return &out
}
// PublicShareURL builds share/download URLs using pre-resolved host and scheme (see web/proxy.go).
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, token string) string {
tokenParam := ""
if token != "" && isDirectDownload {
tokenParam = fmt.Sprintf("&token=%s", url.QueryEscape(token))
func PublicShareURL(host, scheme, hash string, isDirectDownload bool, hasPassword bool) string {
if isDirectDownload && hasPassword {
if settings.Config.Http.ExternalUrl != "" {
return fmt.Sprintf("%s%spublic/share/%s?download=true",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
}
return fmt.Sprintf("%s://%s%spublic/share/%s?download=true", scheme, host, settings.Config.Http.BaseURL, hash)
}
if settings.Config.Http.ExternalUrl != "" {
if isDirectDownload {
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash, tokenParam)
return fmt.Sprintf("%s%spublic/api/resources/download?hash=%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
}
return fmt.Sprintf("%s%spublic/share/%s",
settings.Config.Http.ExternalUrl, settings.Config.Http.BaseURL, hash)
}
if isDirectDownload {
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s",
scheme, host, settings.Config.Http.BaseURL, hash, tokenParam)
return fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s",
scheme, host, settings.Config.Http.BaseURL, hash)
}
return fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, settings.Config.Http.BaseURL, hash)
}
@@ -0,0 +1,31 @@
package share
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestPublicShareURLPasswordProtectedDirectLink(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.BaseURL = "/files/"
got := PublicShareURL("example.com", "https", "abc123", true, true)
want := "https://example.com/files/public/share/abc123?download=true"
if got != want {
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
}
}
func TestPublicShareURLDirectDownloadWithoutPassword(t *testing.T) {
orig := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = orig })
settings.Config.Http.BaseURL = "/files/"
got := PublicShareURL("example.com", "https", "abc123", true, false)
want := "https://example.com/files/public/api/resources/download?hash=abc123"
if got != want {
t.Fatalf("PublicShareURL() = %q, want %q", got, want)
}
}
-1
View File
@@ -112,7 +112,6 @@ type Share struct {
ShareColumns
PasswordHash string `json:"password_hash,omitempty"`
UserID uint64 `json:"userID,omitempty"`
Token string `json:"token,omitempty"`
UserDownloads map[string]int `json:"userDownloads,omitempty"`
Version int `json:"version,omitempty"`
SourcePath string `json:"sourcePath,omitempty"`
@@ -5,9 +5,8 @@ import (
"fmt"
)
// currentSchemaVersion is the SQLite schema marker for this codebase (version 1).
// BoltDB has no schema_version; importing via cmd/migrate builds this SQLite shape directly.
const currentSchemaVersion = 1
// currentSchemaVersion is the SQLite schema marker for this codebase.
const currentSchemaVersion = 2
// Schema creates all tables for the SQLite database
func createSchema(db *sql.DB) error {
@@ -39,7 +38,6 @@ func createSchema(db *sql.DB) error {
expire INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
password_hash TEXT,
token TEXT,
user_downloads TEXT,
share_settings TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 0
@@ -183,6 +181,10 @@ func runMigrations(db *sql.DB, fromVersion int) error {
switch v {
case 1:
// Canonical schema is createSchema + Bolt import; no step migrations.
case 2:
if err := normalizeLegacyShareTokens(db); err != nil {
return err
}
default:
return fmt.Errorf("unknown schema version: %d", v)
}
+55 -35
View File
@@ -40,9 +40,7 @@ func scanShareUserID(s string, dest *uint64) error {
// GetShareByHash retrieves a share by hash
func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE hash = ?`
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE hash = ?`
var link share.Share
var userIDStr string
@@ -56,7 +54,6 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
&link.Expire,
&link.Downloads,
&link.PasswordHash,
&link.Token,
&userDownloadsJSON,
&shareSettingsJSON,
&link.Version,
@@ -87,9 +84,7 @@ func (s *SQLStore) GetShareByHash(hash string) (*share.Share, error) {
// GetSharesByUserID retrieves all non-expired shares for an owner user id.
func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
now := time.Now().Unix()
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE user_id = ? AND (expire = 0 OR expire > ?)
ORDER BY path`
rows, err := s.db.Query(query, shareUserIDDB(userID), now)
@@ -103,9 +98,7 @@ func (s *SQLStore) GetSharesByUserID(userID uint64) ([]*share.Share, error) {
// GetSharesBySourcePath retrieves shares for a specific source and path
func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, error) {
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE source = ? AND path = ? ORDER BY hash`
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? ORDER BY hash`
rows, err := s.db.Query(query, source, path)
if err != nil {
@@ -119,9 +112,7 @@ func (s *SQLStore) GetSharesBySourcePath(source, path string) ([]*share.Share, e
// GetSharesBySourcePathUser retrieves shares for a specific source, path, and owner user id.
func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64) ([]*share.Share, error) {
now := time.Now().Unix()
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE source = ? AND path = ? AND user_id = ?
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ?
AND (expire = 0 OR expire > ?) ORDER BY hash`
rows, err := s.db.Query(query, source, path, shareUserIDDB(userID), now)
@@ -135,9 +126,7 @@ func (s *SQLStore) GetSharesBySourcePathUser(source, path string, userID uint64)
// GetPermanentShare retrieves a permanent share (expire = 0) for source, path, and owner.
func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share.Share, error) {
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE source = ? AND path = ? AND user_id = ? AND expire = 0
LIMIT 1`
var link share.Share
@@ -152,7 +141,6 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
&link.Expire,
&link.Downloads,
&link.PasswordHash,
&link.Token,
&userDownloadsJSON,
&shareSettingsJSON,
&link.Version,
@@ -183,9 +171,7 @@ func (s *SQLStore) GetPermanentShare(source, path string, userID uint64) (*share
// ListAllShares retrieves all non-expired shares
func (s *SQLStore) ListAllShares() ([]*share.Share, error) {
now := time.Now().Unix()
query := `SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
query := `SELECT ` + shareSelectColumns + ` FROM shares WHERE expire = 0 OR expire > ? ORDER BY path`
rows, err := s.db.Query(query, now)
if err != nil {
@@ -207,24 +193,59 @@ func (s *SQLStore) SaveShare(link *share.Share) error {
return fmt.Errorf("failed to marshal share settings: %w", err)
}
query := `INSERT OR REPLACE INTO shares
hasLegacyToken, err := s.sharesHasLegacyTokenColumn()
if err != nil {
return fmt.Errorf("failed to inspect shares schema: %w", err)
}
legacyToken := ""
if hasLegacyToken {
legacyToken, err = s.legacyShareToken(link.Hash)
if err != nil {
return err
}
}
var query string
var args []any
if hasLegacyToken {
query = `INSERT OR REPLACE INTO shares
(hash, user_id, source, path, expire, downloads, password_hash,
token, user_downloads, share_settings, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
args = []any{
link.Hash,
shareUserIDDB(link.UserID),
link.SourcePath,
link.Path,
link.Expire,
link.Downloads,
link.PasswordHash,
legacyToken,
userDownloadsJSON,
shareSettingsJSON,
link.Version,
}
} else {
query = `INSERT OR REPLACE INTO shares
(hash, user_id, source, path, expire, downloads, password_hash,
user_downloads, share_settings, version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
args = []any{
link.Hash,
shareUserIDDB(link.UserID),
link.SourcePath,
link.Path,
link.Expire,
link.Downloads,
link.PasswordHash,
userDownloadsJSON,
shareSettingsJSON,
link.Version,
}
}
_, err = s.db.Exec(query,
link.Hash,
shareUserIDDB(link.UserID),
link.SourcePath,
link.Path,
link.Expire,
link.Downloads,
link.PasswordHash,
link.Token,
userDownloadsJSON,
shareSettingsJSON,
link.Version,
)
_, err = s.db.Exec(query, args...)
if err != nil {
return fmt.Errorf("failed to save share: %w", err)
}
@@ -299,7 +320,6 @@ func (s *SQLStore) scanShares(rows *sql.Rows) ([]*share.Share, error) {
&link.Expire,
&link.Downloads,
&link.PasswordHash,
&link.Token,
&userDownloadsJSON,
&shareSettingsJSON,
&link.Version,
@@ -0,0 +1,81 @@
package sqldb
import (
"database/sql"
"fmt"
)
const shareSelectColumns = `hash, user_id, source, path, expire, downloads,
password_hash, user_downloads, share_settings, version`
func sharesTableHasColumn(db *sql.DB, column string) (bool, error) {
rows, err := db.Query(`PRAGMA table_info(shares)`)
if err != nil {
return false, fmt.Errorf("read shares schema: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
cid int
name string
colType string
notnull int
dfltValue sql.NullString
pk int
)
if err := rows.Scan(&cid, &name, &colType, &notnull, &dfltValue, &pk); err != nil {
return false, fmt.Errorf("scan shares schema: %w", err)
}
if name == column {
return true, nil
}
}
if err := rows.Err(); err != nil {
return false, fmt.Errorf("iterate shares schema: %w", err)
}
return false, nil
}
// normalizeLegacyShareTokens keeps rollback compatibility with releases that still
// scan shares.token into a Go string (NULL is unsupported there).
func normalizeLegacyShareTokens(db *sql.DB) error {
hasToken, err := sharesTableHasColumn(db, "token")
if err != nil {
return err
}
if !hasToken {
return nil
}
if _, err := db.Exec(`UPDATE shares SET token = '' WHERE token IS NULL`); err != nil {
return fmt.Errorf("normalize legacy share tokens: %w", err)
}
return nil
}
func (s *SQLStore) sharesHasLegacyTokenColumn() (bool, error) {
return sharesTableHasColumn(s.db, "token")
}
func (s *SQLStore) legacyShareToken(hash string) (string, error) {
hasToken, err := s.sharesHasLegacyTokenColumn()
if err != nil {
return "", err
}
if !hasToken {
return "", nil
}
var token sql.NullString
err = s.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, hash).Scan(&token)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("read legacy share token: %w", err)
}
if !token.Valid {
return "", nil
}
return token.String, nil
}
@@ -0,0 +1,171 @@
package sqldb
import (
"database/sql"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
)
func createLegacySharesDB(t *testing.T) *SQLStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "legacy-shares.db")
store, _, err := NewSQLStore(dbPath)
if err != nil {
t.Fatalf("NewSQLStore: %v", err)
}
_, err = store.db.Exec(`DROP TABLE shares`)
if err != nil {
t.Fatalf("drop shares: %v", err)
}
_, err = store.db.Exec(`
CREATE TABLE shares (
hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
source TEXT NOT NULL,
path TEXT NOT NULL,
expire INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
password_hash TEXT,
token TEXT,
user_downloads TEXT,
share_settings TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 0
)`)
if err != nil {
t.Fatalf("create legacy shares table: %v", err)
}
_, err = store.db.Exec(`
INSERT INTO shares (
hash, user_id, source, path, expire, downloads, password_hash, token,
user_downloads, share_settings, version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"legacy-null-token",
"1",
"/data",
"/docs/file.txt",
0,
0,
"",
nil,
"{}",
`{"shareType":"normal"}`,
1,
)
if err != nil {
t.Fatalf("insert legacy share: %v", err)
}
return store
}
func scanLegacyShareToken(t *testing.T, store *SQLStore, hash string) string {
t.Helper()
var token string
err := store.db.QueryRow(`
SELECT hash, user_id, source, path, expire, downloads,
password_hash, token, user_downloads, share_settings, version
FROM shares WHERE hash = ?`, hash).Scan(
new(string),
new(string),
new(string),
new(string),
new(int64),
new(int),
new(string),
&token,
new([]byte),
new([]byte),
new(int),
)
if err != nil {
t.Fatalf("scan legacy share row: %v", err)
}
return token
}
func TestMigrationNormalizesNullLegacyShareToken(t *testing.T) {
store := createLegacySharesDB(t)
if err := runMigrations(store.db, 1); err != nil {
t.Fatalf("runMigrations: %v", err)
}
token := scanLegacyShareToken(t, store, "legacy-null-token")
if token != "" {
t.Fatalf("token = %q, want empty string after migration", token)
}
links, err := store.ListAllShares()
if err != nil {
t.Fatalf("ListAllShares: %v", err)
}
if len(links) != 1 || links[0].Hash != "legacy-null-token" {
t.Fatalf("unexpected shares loaded: %#v", links)
}
}
func TestSaveSharePreservesLegacyTokenColumn(t *testing.T) {
store := createLegacySharesDB(t)
if err := normalizeLegacyShareTokens(store.db); err != nil {
t.Fatalf("normalizeLegacyShareTokens: %v", err)
}
_, err := store.db.Exec(`UPDATE shares SET token = ? WHERE hash = ?`, "keep-me", "legacy-null-token")
if err != nil {
t.Fatalf("seed legacy token: %v", err)
}
link, err := store.GetShareByHash("legacy-null-token")
if err != nil {
t.Fatalf("GetShareByHash: %v", err)
}
link.Downloads = 2
if err := store.SaveShare(link); err != nil {
t.Fatalf("SaveShare: %v", err)
}
token := scanLegacyShareToken(t, store, "legacy-null-token")
if token != "keep-me" {
t.Fatalf("token = %q, want keep-me", token)
}
var tokenSQL sql.NullString
if err := store.db.QueryRow(`SELECT token FROM shares WHERE hash = ?`, "legacy-null-token").Scan(&tokenSQL); err != nil {
t.Fatalf("select token: %v", err)
}
if !tokenSQL.Valid {
t.Fatal("expected legacy token column to remain non-null after save")
}
}
func TestSaveShareSetsEmptyLegacyTokenForNewShare(t *testing.T) {
store := createLegacySharesDB(t)
if err := normalizeLegacyShareTokens(store.db); err != nil {
t.Fatalf("normalizeLegacyShareTokens: %v", err)
}
newShare := &share.Share{
ShareSettings: share.ShareSettings{
FrontendShareInfo: share.FrontendShareInfo{ShareType: "normal"},
},
ShareColumns: share.ShareColumns{
Hash: "brand-new-share",
Path: "/new/file.txt",
},
SourcePath: "/data",
UserID: 1,
Version: 1,
}
if err := store.SaveShare(newShare); err != nil {
t.Fatalf("SaveShare: %v", err)
}
token := scanLegacyShareToken(t, store, "brand-new-share")
if token != "" {
t.Fatalf("token = %q, want empty string for new share", token)
}
}
+5
View File
@@ -310,6 +310,11 @@ func GetSourceConfig() *SourceConfigProvider {
return sourceConfig
}
// GetSourceNameResolver returns the current source name resolver, or nil if unset.
func GetSourceNameResolver() SourceNameResolver {
return sourceNameResolver
}
// SourceConfigLoaded reports whether SetSourceConfig has been called (needed for ResolveSourceKey).
func SourceConfigLoaded() bool {
return sourceConfig != nil
+2
View File
@@ -23,7 +23,9 @@ var (
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
ErrNotViewable = errors.New("directory or item is not viewable")
ErrPathEscapesScope = errors.New("resolved path escapes scope")
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
ErrInvalidLoginMethod = errors.New("invalid login method")
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
ErrUseMediaStream = errors.New("use /media/stream for audio and video")
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
+37 -17
View File
@@ -2,7 +2,9 @@ package ffmpeg
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
@@ -35,24 +37,42 @@ func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.Subtit
func mapSubtitleTracks(tracks []goffmpeg.SubtitleTrack) []utils.SubtitleTrack {
subtitles := make([]utils.SubtitleTrack, 0, len(tracks))
mapper := &subtitleTrackMapper{
seenLang: make(map[string]int),
}
for _, stream := range tracks {
index := stream.Index
track := utils.SubtitleTrack{
Index: &index,
Codec: stream.Codec,
Language: stream.Language,
Title: stream.Title,
Embedded: true,
}
baseName := "Embedded Subtitle " + strconv.Itoa(stream.Index)
if track.Title != "" {
track.Name = baseName + " (" + track.Title + ")"
} else if track.Language != "" {
track.Name = baseName + " (" + track.Language + ")"
} else {
track.Name = baseName
}
subtitles = append(subtitles, track)
subtitles = append(subtitles, mapper.track(stream))
}
return subtitles
}
type subtitleTrackMapper struct {
seenLang map[string]int
}
func (m *subtitleTrackMapper) track(stream goffmpeg.SubtitleTrack) utils.SubtitleTrack {
index := stream.Index
lang := strings.TrimSpace(stream.Language)
return utils.SubtitleTrack{
Index: &index,
Codec: stream.Codec,
Language: lang,
Srclang: m.srclang(lang, stream.Index),
Title: stream.Title,
Embedded: true,
Name: "Track " + strconv.Itoa(stream.Index),
}
}
// srclang must be unique per stream so Plyr/HTML can switch tracks with the same Language.
func (m *subtitleTrackMapper) srclang(language string, streamIndex int) string {
lang := strings.TrimSpace(strings.ToLower(language))
if lang != "" {
count := m.seenLang[lang]
m.seenLang[lang] = count + 1
if count == 0 {
return lang
}
}
return fmt.Sprintf("x-track-%d", streamIndex)
}
+100 -7
View File
@@ -6,6 +6,38 @@ import (
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
)
func TestMapSubtitleTracks_uniqueUndefinedLanguage(t *testing.T) {
tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{
{Index: 2},
{Index: 3},
})
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
}
if tracks[0].Name != "Track 2" {
t.Fatalf("tracks[0].Name = %q, want Track 2", tracks[0].Name)
}
if tracks[1].Name != "Track 3" {
t.Fatalf("tracks[1].Name = %q, want Track 3", tracks[1].Name)
}
}
func TestMapSubtitleTracks_duplicateLanguage(t *testing.T) {
tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{
{Index: 2, Language: "chi", Title: "Simplified Chinese"},
{Index: 3, Language: "chi", Title: "Traditional Chinese"},
})
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
}
if tracks[0].Name != "Track 2" {
t.Fatalf("tracks[0].Name = %q, want Track 2", tracks[0].Name)
}
if tracks[1].Name != "Track 3" {
t.Fatalf("tracks[1].Name = %q, want Track 3", tracks[1].Name)
}
}
func TestMapSubtitleTracks_uniqueNames(t *testing.T) {
tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{
{Index: 0, Title: "English", Language: "eng"},
@@ -17,11 +49,72 @@ func TestMapSubtitleTracks_uniqueNames(t *testing.T) {
if tracks[0].Name == tracks[1].Name {
t.Fatalf("duplicate subtitle names: %q", tracks[0].Name)
}
if tracks[0].Index == nil || *tracks[0].Index != 0 {
t.Fatalf("tracks[0].Index = %v, want 0", tracks[0].Index)
if tracks[0].Name != "Track 0" {
t.Fatalf("tracks[0].Name = %q, want Track 0", tracks[0].Name)
}
if tracks[1].Index == nil || *tracks[1].Index != 1 {
t.Fatalf("tracks[1].Index = %v, want 1", tracks[1].Index)
if tracks[1].Name != "Track 1" {
t.Fatalf("tracks[1].Name = %q, want Track 1", tracks[1].Name)
}
}
func TestMapSubtitleTracks_duplicateLanguageNoTitle(t *testing.T) {
tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{
{Index: 2, Language: "eng"},
{Index: 3, Language: "eng"},
})
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2", len(tracks))
}
if tracks[0].Name != "Track 2" {
t.Fatalf("tracks[0].Name = %q, want Track 2", tracks[0].Name)
}
if tracks[1].Name != "Track 3" {
t.Fatalf("tracks[1].Name = %q, want Track 3", tracks[1].Name)
}
}
func TestMapSubtitleTracks_mixedEngAndUndefined(t *testing.T) {
tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{
{Index: 2, Language: "eng"},
{Index: 3},
{Index: 4},
})
if len(tracks) != 3 {
t.Fatalf("len(tracks) = %d, want 3", len(tracks))
}
if tracks[0].Name != "Track 2" {
t.Fatalf("tracks[0].Name = %q", tracks[0].Name)
}
if tracks[1].Name != "Track 3" {
t.Fatalf("tracks[1].Name = %q", tracks[1].Name)
}
if tracks[2].Name != "Track 4" {
t.Fatalf("tracks[2].Name = %q", tracks[2].Name)
}
}
func TestMapSubtitleTracks_multiLanguageMenu(t *testing.T) {
tracks := mapSubtitleTracks([]goffmpeg.SubtitleTrack{
{Index: 4, Language: "fra"},
{Index: 5, Language: "ger"},
{Index: 6, Language: "ger"},
{Index: 7, Language: "eng"},
{Index: 8, Language: "eng"},
})
wantNames := []string{"Track 4", "Track 5", "Track 6", "Track 7", "Track 8"}
if len(tracks) != len(wantNames) {
t.Fatalf("len(tracks) = %d, want %d", len(tracks), len(wantNames))
}
for i, name := range wantNames {
if tracks[i].Name != name {
t.Fatalf("tracks[%d].Name = %q, want %q", i, tracks[i].Name, name)
}
}
wantSrclang := []string{"fra", "ger", "x-track-6", "eng", "x-track-8"}
for i, srclang := range wantSrclang {
if tracks[i].Srclang != srclang {
t.Fatalf("tracks[%d].Srclang = %q, want %q", i, tracks[i].Srclang, srclang)
}
}
}
@@ -34,17 +127,17 @@ func TestMapSubtitleTracks_nameFormats(t *testing.T) {
{
name: "title only",
stream: goffmpeg.SubtitleTrack{Index: 3, Title: "Director's Commentary"},
wantName: "Embedded Subtitle 3 (Director's Commentary)",
wantName: "Track 3",
},
{
name: "language only",
stream: goffmpeg.SubtitleTrack{Index: 5, Language: "deu"},
wantName: "Embedded Subtitle 5 (deu)",
wantName: "Track 5",
},
{
name: "no title or language",
stream: goffmpeg.SubtitleTrack{Index: 7},
wantName: "Embedded Subtitle 7",
wantName: "Track 7",
},
}
+60 -7
View File
@@ -5,6 +5,8 @@ import (
"context"
"errors"
"fmt"
"image"
"image/color"
"io/fs"
"os"
"path/filepath"
@@ -28,8 +30,9 @@ const (
// iconSize defines an icon size to generate
type iconSize struct {
size int
name string
size int
name string
maskable bool
}
// determineSourceType determines which type of favicon source we have
@@ -153,13 +156,16 @@ func handleRasterFavicon(faviconPath string) ([]byte, error) {
func getIconSizesToGenerate() []iconSize {
return []iconSize{
// Browser favicon (32x32 PNG for Safari/older browsers)
{32, "favicon-32x32.png"},
{32, "favicon-32x32.png", false},
// PWA icons (PNG required for Apple PWA compatibility)
{192, "pwa-icon-192.png"},
{256, "pwa-icon-256.png"}, // Also serves as Windows tile
{512, "pwa-icon-512.png"},
{192, "pwa-icon-192.png", false},
{256, "pwa-icon-256.png", false}, // Also serves as Windows tile
{512, "pwa-icon-512.png", false},
// Maskable PWA icons: art inset into the Android adaptive-icon safe zone
{192, "pwa-icon-maskable-192.png", true},
{512, "pwa-icon-maskable-512.png", true},
// Platform-specific icons
{180, "apple-touch-icon.png"}, // iOS home screen (required for Apple)
{180, "apple-touch-icon.png", false}, // iOS home screen (required for Apple)
}
}
@@ -193,8 +199,55 @@ func generateIconSizes(sourceData []byte) error {
return nil
}
// generateMaskableIcon insets the art into the safe zone on an opaque background, since launchers zoom maskable icons to fill the mask edge-to-edge
func generateMaskableIcon(sourceData []byte, icon iconSize) error {
img, err := imaging.Decode(bytes.NewReader(sourceData))
if err != nil {
return fmt.Errorf("failed to decode source: %w", err)
}
// Android adaptive-icon content ratio (72/108 ≈ 2/3) keeps square art inside every mask shape
inner := icon.size * 2 / 3
art := imaging.Fit(img, inner, inner, imaging.Lanczos)
canvas := imaging.PasteCenter(imaging.New(icon.size, icon.size, maskableBackground(img)), art)
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer outFile.Close()
if err := imaging.Encode(outFile, canvas, imaging.PNG); err != nil {
os.Remove(outputPath)
return fmt.Errorf("encode failed: %w", err)
}
return nil
}
// maskableBackground samples the art's corners then edge midpoints for an opaque pad color, else white
func maskableBackground(img image.Image) color.Color {
b := img.Bounds()
cx, cy := (b.Min.X+b.Max.X)/2, (b.Min.Y+b.Max.Y)/2
points := []image.Point{
{b.Min.X, b.Min.Y}, {b.Max.X - 1, b.Min.Y}, {b.Min.X, b.Max.Y - 1}, {b.Max.X - 1, b.Max.Y - 1},
{cx, b.Min.Y}, {cx, b.Max.Y - 1}, {b.Min.X, cy}, {b.Max.X - 1, cy},
}
for _, p := range points {
r, g, bl, a := img.At(p.X, p.Y).RGBA()
if a == 0xffff {
return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(bl >> 8), A: 255}
}
}
return color.White
}
// generateSingleIcon generates a single icon file at the specified size
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
if icon.maskable {
return generateMaskableIcon(sourceData, icon)
}
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
// Create output file
+64
View File
@@ -0,0 +1,64 @@
package icons
import (
"image"
"image/color"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestMaskableBackgroundOpaqueCorner(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
got := maskableBackground(img)
want := color.NRGBA{R: 10, G: 20, B: 30, A: 255}
if got != want {
t.Fatalf("opaque corner: got %#v, want %#v", got, want)
}
}
func TestMaskableBackgroundOpaqueEdge(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
img.Set(6, 0, color.RGBA{R: 200, G: 100, B: 50, A: 255})
got := maskableBackground(img)
want := color.NRGBA{R: 200, G: 100, B: 50, A: 255}
if got != want {
t.Fatalf("opaque edge: got %#v, want %#v", got, want)
}
}
func TestMaskableBackgroundAllTransparent(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
if got := maskableBackground(img); got != color.White {
t.Fatalf("all transparent: got %#v, want white", got)
}
}
func TestDefaultBackgroundColor(t *testing.T) {
saved := settings.Config
defer func() { settings.Config = saved }()
dark := true
light := false
settings.Config.Frontend.Styling.DarkBackground = "#111111"
settings.Config.Frontend.Styling.LightBackground = "#ffffff"
settings.Config.UserDefaults.UI.DarkMode = &dark
if got := settings.DefaultBackgroundColor(); got != "#111111" {
t.Fatalf("dark branch: got %q, want %q", got, "#111111")
}
settings.Config.UserDefaults.UI.DarkMode = &light
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
t.Fatalf("light branch (dark disabled): got %q, want %q", got, "#ffffff")
}
settings.Config.UserDefaults.UI.DarkMode = nil
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
t.Fatalf("light branch (unset): got %q, want %q", got, "#ffffff")
}
}
+20 -6
View File
@@ -44,14 +44,14 @@ func pwaManifestName(name string) string {
}
// generatePWAManifest creates the PWA manifest structure
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
func generatePWAManifest(name, description, baseURL, themeColor, backgroundColor, pwaIcon192, pwaIcon256, pwaIcon512, pwaIconMaskable192, pwaIconMaskable512 string) PWAManifest {
return PWAManifest{
Name: pwaManifestName(name),
ID: baseURL,
Scope: baseURL,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
BackgroundColor: backgroundColor,
ThemeColor: themeColor,
Description: description,
Icons: []PWAIcon{
@@ -65,7 +65,7 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
Src: pwaIcon256,
Sizes: "256x256",
Type: "image/png",
Purpose: "any maskable",
Purpose: "any",
},
{
Src: pwaIcon512,
@@ -73,6 +73,18 @@ func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwa
Type: "image/png",
Purpose: "any",
},
{
Src: pwaIconMaskable192,
Sizes: "192x192",
Type: "image/png",
Purpose: "maskable",
},
{
Src: pwaIconMaskable512,
Sizes: "512x512",
Type: "image/png",
Purpose: "maskable",
},
},
}
}
@@ -85,11 +97,13 @@ func InitializePWAManifest() {
staticURL := config.Http.BaseURL + "public/static"
title := config.Frontend.Name
description := config.Frontend.Description
defaultThemeColor := "#455a64"
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
pwaIconMaskable192 := staticURL + "/icons/pwa-icon-maskable-192.png"
pwaIconMaskable512 := staticURL + "/icons/pwa-icon-maskable-512.png"
CachedManifest = generatePWAManifest(title, description, config.Http.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
backgroundColor := settings.DefaultBackgroundColor()
CachedManifest = generatePWAManifest(title, description, config.Http.BaseURL, backgroundColor, backgroundColor, pwaIcon192, pwaIcon256, pwaIcon512, pwaIconMaskable192, pwaIconMaskable512)
}
+26 -12
View File
@@ -56,6 +56,8 @@ func ExtractEmbeddedPreview(ctx context.Context, path string) ([]byte, error) {
switch {
case ext == ".cr3":
data, err = extimagemeta.PreviewCR3(f)
case ext == ".raf":
data, err = extractRAFEmbeddedPreview(f)
case isRawImageExtension(ext):
data, err = extractTIFFEmbeddedPreview(f)
case isHEICExtension(ext):
@@ -81,19 +83,11 @@ func extractTIFFEmbeddedPreview(f *os.File) ([]byte, error) {
return nil, err
}
for _, c := range previewCandidates(ex) {
if c.length == 0 {
continue
}
data, readErr := readFileRange(f, c.offset, c.length)
if readErr != nil || len(data) == 0 {
continue
}
if IsJPEG(data) {
return data, nil
}
candidates := previewCandidates(ex)
if scanCandidates, scanErr := collectTIFFPreviewCandidates(f); scanErr == nil {
candidates = append(candidates, scanCandidates...)
}
return nil, nil
return readLargestJPEGPreview(f, candidates)
}
type previewCandidate struct {
@@ -140,6 +134,26 @@ func previewCandidates(ex exif.Exif) []previewCandidate {
return out
}
func readLargestJPEGPreview(f *os.File, candidates []previewCandidate) ([]byte, error) {
var best []byte
for _, c := range candidates {
if c.length == 0 {
continue
}
data, err := readFileRange(f, c.offset, c.length)
if err != nil || len(data) == 0 || !IsJPEG(data) {
continue
}
if len(data) > len(best) {
best = data
}
}
if len(best) == 0 {
return nil, nil
}
return best, nil
}
const maxPreviewReadSize = 100 * 1024 * 1024 // embedded previews are never larger
func readFileRange(f *os.File, offset, length uint32) ([]byte, error) {
@@ -0,0 +1,272 @@
//go:build !386 && !arm
package imagemeta
import (
"encoding/binary"
"io"
"os"
)
const heicMetadataScanLimit = 32 * 1024 * 1024
type heicTransform struct {
irot uint8
found bool
}
// parseHEICTransform reads the irot property associated with the primary image item.
// Per MIAF, irot takes precedence over informational EXIF Orientation (same as
// ExifTool QuickTime:Rotation, which the previous exiftool integration used).
func parseHEICTransform(f *os.File) heicTransform {
info, err := f.Stat()
if err != nil {
return heicTransform{}
}
size := info.Size()
if size > heicMetadataScanLimit {
size = heicMetadataScanLimit
}
if size < 12 {
return heicTransform{}
}
buf := make([]byte, size)
n, err := f.ReadAt(buf, 0)
if err != nil && err != io.EOF {
return heicTransform{}
}
return parseHEICPrimaryIrot(buf[:n])
}
func parseHEICPrimaryIrot(data []byte) heicTransform {
meta := findBMFFBox(data, "meta")
if meta == nil {
return heicTransform{}
}
primaryID := parsePITMItemID(findBMFFChildBox(meta, "pitm"))
if primaryID == 0 {
return heicTransform{}
}
iprp := findBMFFChildBox(meta, "iprp")
if iprp == nil {
return heicTransform{}
}
ipco := findBMFFChildBox(iprp, "ipco")
ipma := findBMFFChildBox(iprp, "ipma")
if ipco == nil || ipma == nil {
return heicTransform{}
}
properties := parseIPCOProperties(ipco)
indices := parseIPMAPropertyIndices(ipma, primaryID)
for _, idx := range indices {
if idx == 0 || int(idx) > len(properties) {
continue
}
prop := properties[idx-1]
if prop.typ == "irot" {
return heicTransform{irot: prop.irot & 0x03, found: true}
}
}
return heicTransform{}
}
func findBMFFBox(data []byte, typ string) []byte {
return findBMFFBoxRange(data, 0, len(data), typ)
}
func findBMFFBoxRange(data []byte, start, end int, typ string) []byte {
pos := start
for pos+8 <= end {
size, header, ok := bmffBoxHeader(data, pos, end)
if !ok || size < header {
break
}
boxEnd := pos + size
if boxEnd > end {
break
}
if string(data[pos+4:pos+8]) == typ {
return data[pos:boxEnd]
}
if isBMFFContainerType(data[pos+4 : pos+8]) {
if child := findBMFFBoxRange(data, pos+header, boxEnd, typ); child != nil {
return child
}
}
pos = boxEnd
}
return nil
}
func findBMFFChildBox(container []byte, typ string) []byte {
payload := bmffBoxPayload(container)
if payload == nil {
return nil
}
return findBMFFBoxRange(payload, 0, len(payload), typ)
}
func isBMFFContainerType(typ []byte) bool {
switch string(typ) {
case "meta", "iprp", "ipco", "moov", "trak", "mdia", "minf", "stbl", "dinf":
return true
default:
return false
}
}
func bmffBoxHeader(data []byte, pos, end int) (size int, header int, ok bool) {
if pos+8 > end {
return 0, 0, false
}
size32 := binary.BigEndian.Uint32(data[pos : pos+4])
if size32 == 0 {
return 0, 0, false
}
if size32 == 1 {
if pos+16 > end {
return 0, 0, false
}
return int(binary.BigEndian.Uint64(data[pos+8 : pos+16])), 16, true
}
return int(size32), 8, true
}
func bmffBoxPayload(box []byte) []byte {
if len(box) < 8 {
return nil
}
typ := string(box[4:8])
header := 8
if binary.BigEndian.Uint32(box[0:4]) == 1 {
header = 16
}
if isBMFFFullBoxType(typ) {
header += 4
}
if header > len(box) {
return nil
}
return box[header:]
}
func isBMFFFullBoxType(typ string) bool {
switch typ {
case "meta", "pitm", "irot", "imir", "ispe", "ipma", "hdlr", "iloc", "iinf", "infe":
return true
default:
return false
}
}
func parsePITMItemID(pitm []byte) uint32 {
payload := bmffBoxPayload(pitm)
if len(pitm) < 12 || payload == nil {
return 0
}
version := pitm[8]
switch version {
case 0:
if len(payload) < 2 {
return 0
}
return uint32(binary.BigEndian.Uint16(payload[0:2]))
default:
if len(payload) < 4 {
return 0
}
return binary.BigEndian.Uint32(payload[0:4])
}
}
type ipcoProperty struct {
typ string
irot uint8
}
func parseIPCOProperties(ipco []byte) []ipcoProperty {
payload := bmffBoxPayload(ipco)
if payload == nil {
return nil
}
var props []ipcoProperty
pos := 0
for pos+8 <= len(payload) {
size, header, ok := bmffBoxHeader(payload, pos, len(payload))
if !ok || size < header || pos+size > len(payload) {
break
}
child := payload[pos : pos+size]
prop := ipcoProperty{typ: string(child[4:8])}
if prop.typ == "irot" && len(child) >= 13 {
prop.irot = child[12] & 0x03
}
props = append(props, prop)
pos += size
}
return props
}
func parseIPMAPropertyIndices(ipma []byte, itemID uint32) []uint8 {
payload := bmffBoxPayload(ipma)
if len(ipma) < 12 || payload == nil || len(payload) < 4 {
return nil
}
version := ipma[8]
entryCount := int(binary.BigEndian.Uint32(payload[0:4]))
pos := 4
for i := 0; i < entryCount && pos < len(payload); i++ {
var id uint32
switch version {
case 0:
if pos+2 > len(payload) {
return nil
}
id = uint32(binary.BigEndian.Uint16(payload[pos : pos+2]))
pos += 2
default:
if pos+4 > len(payload) {
return nil
}
id = binary.BigEndian.Uint32(payload[pos : pos+4])
pos += 4
}
if pos >= len(payload) {
return nil
}
assocCount := int(payload[pos])
pos++
indices := make([]uint8, 0, assocCount)
for j := 0; j < assocCount && pos < len(payload); j++ {
indices = append(indices, payload[pos]&0x7f)
pos++
}
if id == itemID {
return indices
}
}
return nil
}
// heicTransformOrientation maps irot to EXIF-style orientation labels used elsewhere.
func heicTransformOrientation(t heicTransform) string {
if !t.found {
return ""
}
switch t.irot & 0x03 {
case 0:
return "Horizontal (normal)"
case 1:
return "Rotate 270 CW"
case 2:
return "Rotate 180"
case 3:
return "Rotate 90 CW"
default:
return ""
}
}
@@ -0,0 +1,253 @@
//go:build !386 && !arm
package imagemeta
import (
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func TestHeicTransformOrientationMapping(t *testing.T) {
tests := []struct {
name string
t heicTransform
want string
}{
{
name: "irot normal",
t: heicTransform{irot: 0, found: true},
want: "Horizontal (normal)",
},
{
name: "irot 90 cw",
t: heicTransform{irot: 3, found: true},
want: "Rotate 90 CW",
},
{
name: "missing",
t: heicTransform{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := heicTransformOrientation(tt.t); got != tt.want {
t.Fatalf("heicTransformOrientation() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseHEICPrimaryIrotUsesPrimaryItem(t *testing.T) {
data := buildSyntheticHEIC(heicFixture{
primaryItemID: 1,
primaryIrot: 0,
thumbnailItem: &heicItemFixture{itemID: 2, irot: 3},
decoyIrotInMDAT: true,
})
got := parseHEICPrimaryIrot(data)
if !got.found || got.irot != 0 {
t.Fatalf("parseHEICPrimaryIrot() = %+v, want irot=0 from primary item", got)
}
}
func TestParseHEICPrimaryIrotRotate90(t *testing.T) {
data := buildSyntheticHEIC(heicFixture{
primaryItemID: 1,
primaryIrot: 3,
})
got := parseHEICPrimaryIrot(data)
if !got.found || got.irot != 3 {
t.Fatalf("parseHEICPrimaryIrot() = %+v, want irot=3", got)
}
}
func TestParseHEICPrimaryIrotMissingPITM(t *testing.T) {
data := buildSyntheticHEIC(heicFixture{
omitPITM: true,
primaryItemID: 1,
primaryIrot: 0,
})
got := parseHEICPrimaryIrot(data)
if got.found {
t.Fatalf("parseHEICPrimaryIrot() = %+v, want not found without pitm", got)
}
}
func TestGetOrientationSyntheticHEIC(t *testing.T) {
path := writeTempHEIC(t, buildSyntheticHEIC(heicFixture{
primaryItemID: 1,
primaryIrot: 0,
thumbnailItem: &heicItemFixture{itemID: 2, irot: 3},
}))
if got := GetOrientation(t.Context(), path); got != "Horizontal (normal)" {
t.Fatalf("GetOrientation() = %q, want Horizontal (normal)", got)
}
}
func TestGetOrientationSyntheticHEICRotate(t *testing.T) {
path := writeTempHEIC(t, buildSyntheticHEIC(heicFixture{
primaryItemID: 1,
primaryIrot: 3,
}))
if got := GetOrientation(t.Context(), path); got != "Rotate 90 CW" {
t.Fatalf("GetOrientation() = %q, want Rotate 90 CW", got)
}
}
type heicItemFixture struct {
itemID uint16
irot uint8
}
type heicFixture struct {
primaryItemID uint16
primaryIrot uint8
thumbnailItem *heicItemFixture
omitPITM bool
decoyIrotInMDAT bool
}
func writeTempHEIC(t *testing.T, data []byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "sample.heic")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
return path
}
func buildSyntheticHEIC(fix heicFixture) []byte {
var properties []byte
propertyIndex := uint8(1)
appendProperty := func(box []byte) uint8 {
idx := propertyIndex
properties = append(properties, box...)
propertyIndex++
return idx
}
primaryIspe := appendProperty(bmffFullBox("ispe", 0, []byte{0, 0, 1, 0, 0, 0, 2, 0}))
primaryIrot := appendProperty(bmffIrotBox(fix.primaryIrot))
var ipmaEntries []ipmaEntryFixture
primaryAssoc := []ipmaAssocFixture{
{index: primaryIspe, essential: true},
{index: primaryIrot, essential: false},
}
ipmaEntries = append(ipmaEntries, ipmaEntryFixture{
itemID: fix.primaryItemID,
assoc: primaryAssoc,
})
if fix.thumbnailItem != nil {
thumbIspe := appendProperty(bmffFullBox("ispe", 0, []byte{0, 0, 0, 64, 0, 0, 0, 64}))
thumbIrot := appendProperty(bmffIrotBox(fix.thumbnailItem.irot))
ipmaEntries = append(ipmaEntries, ipmaEntryFixture{
itemID: fix.thumbnailItem.itemID,
assoc: []ipmaAssocFixture{
{index: thumbIspe, essential: true},
{index: thumbIrot, essential: false},
},
})
}
ipco := bmffBox("ipco", properties)
ipma := bmffIPMABox(ipmaEntries)
iprp := bmffBox("iprp", append(ipco, ipma...))
var metaChildren []byte
if !fix.omitPITM {
metaChildren = append(metaChildren, bmffPITMBox(fix.primaryItemID)...)
}
metaChildren = append(metaChildren, iprp...)
meta := bmffFullBox("meta", 0, metaChildren)
out := []byte{}
out = append(out, bmffFtypBox()...)
out = append(out, meta...)
if fix.decoyIrotInMDAT {
decoy := make([]byte, 64)
copy(decoy[4:8], []byte("irot"))
decoy[8] = 3
out = append(out, bmffBox("mdat", decoy)...)
}
return out
}
type ipmaAssocFixture struct {
index uint8
essential bool
}
type ipmaEntryFixture struct {
itemID uint16
assoc []ipmaAssocFixture
}
func bmffBox(typ string, payload []byte) []byte {
size := 8 + len(payload)
out := make([]byte, size)
binary.BigEndian.PutUint32(out[0:4], uint32(size))
copy(out[4:8], typ)
copy(out[8:], payload)
return out
}
func bmffFullBox(typ string, version byte, payload []byte) []byte {
size := 12 + len(payload)
out := make([]byte, size)
binary.BigEndian.PutUint32(out[0:4], uint32(size))
copy(out[4:8], typ)
out[8] = version
copy(out[12:], payload)
return out
}
func bmffFtypBox() []byte {
payload := append([]byte("heic"), 0, 0, 0, 0)
payload = append(payload, []byte("mif1")...)
return bmffBox("ftyp", payload)
}
func bmffPITMBox(itemID uint16) []byte {
payload := make([]byte, 2)
binary.BigEndian.PutUint16(payload, itemID)
return bmffFullBox("pitm", 0, payload)
}
func bmffIrotBox(angle uint8) []byte {
return bmffFullBox("irot", 0, []byte{angle & 0x03})
}
func bmffIPMABox(entries []ipmaEntryFixture) []byte {
var payload []byte
payload = appendUint32BE(payload, uint32(len(entries)))
for _, entry := range entries {
payload = appendUint16BE(payload, entry.itemID)
payload = append(payload, byte(len(entry.assoc)))
for _, assoc := range entry.assoc {
value := assoc.index & 0x7f
if assoc.essential {
value |= 0x80
}
payload = append(payload, value)
}
}
return bmffFullBox("ipma", 0, payload)
}
func appendUint32BE(dst []byte, v uint32) []byte {
var b [4]byte
binary.BigEndian.PutUint32(b[:], v)
return append(dst, b[:]...)
}
func appendUint16BE(dst []byte, v uint16) []byte {
var b [2]byte
binary.BigEndian.PutUint16(b[:], v)
return append(dst, b[:]...)
}
+13
View File
@@ -4,6 +4,7 @@ package imagemeta
import (
"context"
"io"
"os"
"path/filepath"
"strings"
@@ -27,9 +28,21 @@ func GetOrientation(ctx context.Context, path string) string {
defer f.Close()
ext := strings.ToLower(filepath.Ext(path))
if isHEICExtension(ext) {
if _, err = f.Seek(0, io.SeekStart); err != nil {
return ""
}
if orient := heicTransformOrientation(parseHEICTransform(f)); orient != "" {
return orient
}
}
var ex exif.Exif
switch ext {
case ".heic", ".heif", ".heics":
if _, err = f.Seek(0, io.SeekStart); err != nil {
return ""
}
ex, err = extimagemeta.DecodeHeif(f)
default:
ex, err = extimagemeta.Decode(f)
+68
View File
@@ -0,0 +1,68 @@
//go:build !386 && !arm
package imagemeta
import (
"encoding/binary"
"io"
"os"
)
const (
rafMagicLen = 16
rafMagic = "FUJIFILMCCD-RAW "
rafJPEGOffsetPos = 0x54
rafJPEGLengthPos = 0x58
rafHeaderMinBytes = 0x5C
)
// extractRAFEmbeddedPreview reads the camera-embedded JPEG from a Fuji RAF file.
// Offset and length are stored as big-endian uint32 at 0x54 and 0x58 in the RAF header directory.
func extractRAFEmbeddedPreview(f *os.File) ([]byte, error) {
info, err := f.Stat()
if err != nil {
return nil, err
}
if info.Size() < rafHeaderMinBytes {
return nil, nil
}
header := make([]byte, rafHeaderMinBytes)
n, err := f.ReadAt(header, 0)
if err != nil && err != io.EOF {
return nil, err
}
if n < rafHeaderMinBytes {
return nil, nil
}
if !isRAFMagic(header[:rafMagicLen]) {
return nil, nil
}
offset := binary.BigEndian.Uint32(header[rafJPEGOffsetPos:rafJPEGLengthPos])
length := binary.BigEndian.Uint32(header[rafJPEGLengthPos:rafHeaderMinBytes])
if offset == 0 || length == 0 {
return nil, nil
}
if int64(offset)+int64(length) > info.Size() {
return nil, nil
}
data, err := readFileRange(f, offset, length)
if err != nil || len(data) == 0 || !IsJPEG(data) {
return nil, nil
}
return data, nil
}
func isRAFMagic(b []byte) bool {
if len(b) < len(rafMagic) {
return false
}
for i := 0; i < len(rafMagic); i++ {
if b[i] != rafMagic[i] {
return false
}
}
return true
}
@@ -0,0 +1,94 @@
//go:build !386 && !arm
package imagemeta
import (
"context"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func TestExtractRAFEmbeddedPreviewSynthetic(t *testing.T) {
jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9}
const jpegOffset = uint32(0x94)
data := make([]byte, int(jpegOffset)+len(jpeg))
copy(data[:rafMagicLen], []byte(rafMagic))
binary.BigEndian.PutUint32(data[rafJPEGOffsetPos:rafJPEGLengthPos], jpegOffset)
binary.BigEndian.PutUint32(data[rafJPEGLengthPos:rafHeaderMinBytes], uint32(len(jpeg)))
copy(data[jpegOffset:], jpeg)
path := filepath.Join(t.TempDir(), "sample.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if !IsJPEG(got) {
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
}
if len(got) != len(jpeg) {
t.Fatalf("preview size = %d, want %d", len(got), len(jpeg))
}
}
func TestExtractRAFEmbeddedPreviewIgnoresInvalidMagic(t *testing.T) {
data := make([]byte, 256)
copy(data[:rafMagicLen], []byte("FUJIFILMCCD-RAW!"))
path := filepath.Join(t.TempDir(), "bad.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatalf("ExtractEmbeddedPreview() = %v, want nil", got)
}
}
func TestExtractRAFEmbeddedPreviewDSCF1276(t *testing.T) {
path := "/Users/steffag/Downloads/DSCF1276.RAF"
if _, err := os.Stat(path); err != nil {
t.Skip(path)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if !IsJPEG(got) {
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
}
if len(got) < 100 {
t.Fatalf("preview size = %d, want >= 100", len(got))
}
}
func TestExtractRAFEmbeddedPreviewRejectsOutOfBoundsOffset(t *testing.T) {
jpeg := []byte{0xff, 0xd8, 0xff, 0xd9}
data := make([]byte, 256)
copy(data[:rafMagicLen], []byte(rafMagic))
binary.BigEndian.PutUint32(data[rafJPEGOffsetPos:rafJPEGLengthPos], 200)
binary.BigEndian.PutUint32(data[rafJPEGLengthPos:rafHeaderMinBytes], uint32(len(jpeg)))
path := filepath.Join(t.TempDir(), "oob.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
got, err := ExtractEmbeddedPreview(context.Background(), path)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Fatalf("ExtractEmbeddedPreview() = %v, want nil", got)
}
}
+248
View File
@@ -0,0 +1,248 @@
//go:build !386 && !arm
package imagemeta
import (
"encoding/binary"
"io"
"os"
)
const (
tiffTagSubIFDs = 0x014a
tiffTagJpgFromRaw = 0x002e
tiffTagJPEGInterchange = 0x0201
tiffTagJPEGInterchangeLen = 0x0202
tiffIFDScanLimit = 4 * 1024 * 1024
tiffTypeByte = 1
tiffTypeASCII = 2
tiffTypeShort = 3
tiffTypeLong = 4
tiffTypeUndefined = 7
maxTIFFIFDsVisited = 128
maxTIFFIFDQueueLen = 128
maxTIFFSubIFDsPerEntry = 16
maxTIFFPreviewCandidates = 32
)
type tiffIFD struct {
tags map[uint16]tiffTag
}
type tiffTag struct {
typ uint16
count uint32
valueOffset uint32
}
// collectTIFFPreviewCandidates walks TIFF IFDs (including SubIFDs) for embedded
// JPEG preview offset/length pairs that imagemeta does not surface.
func collectTIFFPreviewCandidates(f *os.File) ([]previewCandidate, error) {
info, err := f.Stat()
if err != nil {
return nil, err
}
scanSize := info.Size()
if scanSize > tiffIFDScanLimit {
scanSize = tiffIFDScanLimit
}
if scanSize < 8 {
return nil, nil
}
header := make([]byte, scanSize)
n, err := f.ReadAt(header, 0)
if err != nil && err != io.EOF {
return nil, err
}
header = header[:n]
order, ifd0, ok := tiffHeaderIFD0(header)
if !ok {
return nil, nil
}
seen := make(map[uint32]struct{})
var ifds []tiffIFD
var queue []uint32
queue = append(queue, ifd0)
for len(queue) > 0 && len(seen) < maxTIFFIFDsVisited {
off := queue[0]
queue = queue[1:]
if off == 0 {
continue
}
if _, done := seen[off]; done {
continue
}
seen[off] = struct{}{}
ifd, next, subIFDs, err := parseTIFFIFD(header, order, off)
if err != nil {
continue
}
ifds = append(ifds, ifd)
if next != 0 && len(queue) < maxTIFFIFDQueueLen {
queue = append(queue, next)
}
for _, sub := range subIFDs {
if len(queue) >= maxTIFFIFDQueueLen {
break
}
queue = append(queue, sub)
}
}
var out []previewCandidate
for _, ifd := range ifds {
out = appendPreviewCandidates(out, jpgFromRawCandidates(ifd))
out = appendPreviewCandidates(out, jpegInterchangeCandidates(ifd))
if len(out) >= maxTIFFPreviewCandidates {
break
}
}
return out, nil
}
func appendPreviewCandidates(dst, src []previewCandidate) []previewCandidate {
remaining := maxTIFFPreviewCandidates - len(dst)
if remaining <= 0 {
return dst
}
if len(src) > remaining {
src = src[:remaining]
}
return append(dst, src...)
}
func jpgFromRawCandidates(ifd tiffIFD) []previewCandidate {
tag, ok := ifd.tags[tiffTagJpgFromRaw]
if !ok || tag.count == 0 {
return nil
}
byteLen := uint32(int(tag.count) * tiffTagByteSize(tag.typ))
if byteLen <= 4 {
return nil
}
return []previewCandidate{{offset: tag.valueOffset, length: byteLen}}
}
func jpegInterchangeCandidates(ifd tiffIFD) []previewCandidate {
offTag, okOff := ifd.tags[tiffTagJPEGInterchange]
lenTag, okLen := ifd.tags[tiffTagJPEGInterchangeLen]
if !okOff || !okLen || offTag.count == 0 || lenTag.count == 0 {
return nil
}
if offTag.typ != tiffTypeLong || lenTag.typ != tiffTypeLong {
return nil
}
offset := offTag.valueOffset
length := lenTag.valueOffset
if length == 0 || length > maxPreviewReadSize {
return nil
}
return []previewCandidate{{offset: offset, length: length}}
}
func tiffHeaderIFD0(data []byte) (binary.ByteOrder, uint32, bool) {
if len(data) < 8 {
return nil, 0, false
}
var order binary.ByteOrder
switch string(data[:2]) {
case "II":
order = binary.LittleEndian
case "MM":
order = binary.BigEndian
default:
return nil, 0, false
}
if order.Uint16(data[2:4]) != 42 {
return nil, 0, false
}
return order, order.Uint32(data[4:8]), true
}
func parseTIFFIFD(data []byte, order binary.ByteOrder, offset uint32) (ifd tiffIFD, next uint32, subIFDs []uint32, err error) {
ifd.tags = make(map[uint16]tiffTag)
off := int(offset)
if off < 0 || off+2 > len(data) {
return ifd, 0, nil, io.ErrUnexpectedEOF
}
count := int(order.Uint16(data[off : off+2]))
entryBase := off + 2
for i := 0; i < count; i++ {
entry := entryBase + i*12
if entry+12 > len(data) {
return ifd, 0, nil, io.ErrUnexpectedEOF
}
tagID := order.Uint16(data[entry : entry+2])
typ := order.Uint16(data[entry+2 : entry+4])
cnt := order.Uint32(data[entry+4 : entry+8])
val := order.Uint32(data[entry+8 : entry+12])
ifd.tags[tagID] = tiffTag{typ: typ, count: cnt, valueOffset: val}
if tagID == tiffTagSubIFDs {
subIFDs = limitSubIFDs(tiffTagUint32s(data, order, typ, cnt, val))
}
}
nextOff := entryBase + count*12
if nextOff+4 <= len(data) {
next = order.Uint32(data[nextOff : nextOff+4])
}
return ifd, next, subIFDs, nil
}
func limitSubIFDs(offsets []uint32) []uint32 {
if len(offsets) > maxTIFFSubIFDsPerEntry {
return offsets[:maxTIFFSubIFDsPerEntry]
}
return offsets
}
func tiffTagUint32s(data []byte, order binary.ByteOrder, typ uint16, count uint32, valueOffset uint32) []uint32 {
if count == 0 {
return nil
}
if count > maxTIFFSubIFDsPerEntry {
count = maxTIFFSubIFDsPerEntry
}
var raw []byte
switch {
case typ == tiffTypeLong && count == 1:
var b [4]byte
order.PutUint32(b[:], valueOffset)
raw = b[:]
case tiffTagByteSize(typ)*int(count) <= 4:
var b [4]byte
order.PutUint32(b[:], valueOffset)
raw = b[:count*uint32(tiffTagByteSize(typ))]
default:
off := int(valueOffset)
n := int(count) * tiffTagByteSize(typ)
if off < 0 || off+n > len(data) {
return nil
}
raw = data[off : off+n]
}
out := make([]uint32, 0, count)
for i := 0; i < int(count); i++ {
pos := i * 4
if pos+4 > len(raw) {
break
}
out = append(out, order.Uint32(raw[pos:pos+4]))
}
return out
}
func tiffTagByteSize(typ uint16) int {
switch typ {
case tiffTypeByte, tiffTypeASCII, tiffTypeUndefined:
return 1
case tiffTypeShort:
return 2
case tiffTypeLong:
return 4
default:
return 1
}
}
@@ -0,0 +1,195 @@
//go:build !386 && !arm
package imagemeta
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
var (
smallJPEG = []byte{0xff, 0xd8, 0xff, 0xd9}
largeJPEG = bytes.Repeat([]byte{0xff, 0xd8, 0xff, 0x00}, 512)
)
func TestCollectTIFFPreviewCandidatesPrefersSubIFDLargePreview(t *testing.T) {
data := buildSyntheticTIFF(tiffFixture{
thumbnailJPEG: smallJPEG,
subIFDJPEG: largeJPEG,
})
f := openBytesFile(t, data)
candidates, err := collectTIFFPreviewCandidates(f)
if err != nil {
t.Fatal(err)
}
preview, err := readLargestJPEGPreview(f, candidates)
if err != nil {
t.Fatal(err)
}
if !IsJPEG(preview) {
t.Fatalf("expected JPEG preview, got %d bytes", len(preview))
}
if len(preview) != len(largeJPEG) {
t.Fatalf("preview size = %d, want %d (largest candidate)", len(preview), len(largeJPEG))
}
}
func TestCollectTIFFPreviewCandidatesIgnoresMalformedOffsets(t *testing.T) {
data := buildSyntheticTIFF(tiffFixture{
thumbnailJPEG: smallJPEG,
subIFDJPEG: largeJPEG,
badJPEGInterchg: true,
})
f := openBytesFile(t, data)
candidates, err := collectTIFFPreviewCandidates(f)
if err != nil {
t.Fatal(err)
}
preview, err := readLargestJPEGPreview(f, candidates)
if err != nil {
t.Fatal(err)
}
if len(preview) != len(largeJPEG) {
t.Fatalf("preview size = %d, want %d", len(preview), len(largeJPEG))
}
}
func TestCollectTIFFPreviewCandidatesIgnoresMalformedSubIFDOffset(t *testing.T) {
data := buildSyntheticTIFF(tiffFixture{
thumbnailJPEG: smallJPEG,
subIFDJPEG: largeJPEG,
badJpgFromRaw: true,
})
f := openBytesFile(t, data)
candidates, err := collectTIFFPreviewCandidates(f)
if err != nil {
t.Fatal(err)
}
preview, err := readLargestJPEGPreview(f, candidates)
if err != nil {
t.Fatal(err)
}
if len(preview) != len(smallJPEG) {
t.Fatalf("preview size = %d, want %d", len(preview), len(smallJPEG))
}
}
func TestCollectTIFFPreviewCandidatesBoundsIFDTraversal(t *testing.T) {
data := buildChainedTIFF(maxTIFFIFDsVisited + 16)
f := openBytesFile(t, data)
candidates, err := collectTIFFPreviewCandidates(f)
if err != nil {
t.Fatal(err)
}
if len(candidates) > maxTIFFPreviewCandidates {
t.Fatalf("candidate count = %d, want <= %d", len(candidates), maxTIFFPreviewCandidates)
}
}
type tiffFixture struct {
thumbnailJPEG []byte
subIFDJPEG []byte
badJpgFromRaw bool
badJPEGInterchg bool
}
func openBytesFile(t *testing.T, data []byte) *os.File {
t.Helper()
path := filepath.Join(t.TempDir(), "sample.tiff")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = f.Close() })
return f
}
func buildSyntheticTIFF(fix tiffFixture) []byte {
order := binary.LittleEndian
buf := make([]byte, 64*1024)
thumbOff := uint32(4096)
copy(buf[thumbOff:], fix.thumbnailJPEG)
largeOff := uint32(8192)
copy(buf[largeOff:], fix.subIFDJPEG)
ifd1Off := uint32(256)
ifd0Off := uint32(8)
writeIFD := func(offset uint32, entries []tiffEntry, next uint32) {
off := int(offset)
order.PutUint16(buf[off:off+2], uint16(len(entries)))
entryBase := off + 2
for i, entry := range entries {
pos := entryBase + i*12
order.PutUint16(buf[pos:pos+2], entry.tag)
order.PutUint16(buf[pos+2:pos+4], entry.typ)
order.PutUint32(buf[pos+4:pos+8], entry.count)
order.PutUint32(buf[pos+8:pos+12], entry.value)
}
order.PutUint32(buf[entryBase+len(entries)*12:entryBase+len(entries)*12+4], next)
}
ifd1Entries := []tiffEntry{
{tag: tiffTagJpgFromRaw, typ: tiffTypeUndefined, count: uint32(len(fix.subIFDJPEG)), value: largeOff},
}
if fix.badJpgFromRaw {
ifd1Entries[0].value = 1_000_000
}
writeIFD(ifd1Off, ifd1Entries, 0)
ifd0Entries := []tiffEntry{
{tag: tiffTagSubIFDs, typ: tiffTypeLong, count: 1, value: ifd1Off},
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(fix.thumbnailJPEG))},
}
if fix.badJPEGInterchg {
ifd0Entries[1].value = 2_000_000
}
writeIFD(ifd0Off, ifd0Entries, 0)
copy(buf[0:2], []byte("II"))
order.PutUint16(buf[2:4], 42)
order.PutUint32(buf[4:8], ifd0Off)
end := int(largeOff) + len(fix.subIFDJPEG)
if end < 1024 {
end = 1024
}
return buf[:end]
}
type tiffEntry struct {
tag, typ uint16
count, value uint32
}
func buildChainedTIFF(chainLen int) []byte {
order := binary.LittleEndian
const ifdSize = 6 // 2-byte entry count + 4-byte next IFD pointer
buf := make([]byte, 8+chainLen*ifdSize)
copy(buf[0:2], []byte("II"))
order.PutUint16(buf[2:4], 42)
order.PutUint32(buf[4:8], 8)
for i := 0; i < chainLen; i++ {
off := 8 + i*ifdSize
order.PutUint16(buf[off:off+2], 0)
var next uint32
if i+1 < chainLen {
next = uint32(8 + (i+1)*ifdSize)
}
order.PutUint32(buf[off+2:off+6], next)
}
return buf
}
+1
View File
@@ -0,0 +1 @@
gofitzinclude
+22 -57
View File
@@ -3,18 +3,15 @@
package preview
import (
"bytes"
"context"
"fmt"
"image/jpeg"
"io"
"os"
"runtime"
"strings"
"time"
"github.com/gen2brain/go-fitz"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
func docEnabled() bool {
@@ -23,105 +20,73 @@ func docEnabled() bool {
return true
}
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int, previewSize string) ([]byte, error) {
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Serialize access to the entire go-fitz operation block (required for CGO thread safety)
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
// Lock the current goroutine to a single OS thread for CGo calls
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// Create a 2-second timeout for document generation after acquiring locks
timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
docPath := file.RealPath
// copy file to a temporary location if needed
if strings.HasPrefix(file.Type, "text") && !strings.HasSuffix(file.RealPath, ".txt") {
originalFile, err := os.Open(file.RealPath)
if err != nil {
return nil, fmt.Errorf("text snippet: failed to open original file '%s': %w", file.RealPath, err)
logger.Errorf("text snippet preview: failed to open file '%s': %v", file.Name, err)
return nil, fmt.Errorf("text snippet: failed to open file")
}
defer originalFile.Close() // Ensure original file is closed
buffer := make([]byte, 1024) // Buffer for up to 1KB
n, readErr := originalFile.Read(buffer)
if readErr != nil && readErr != io.EOF { // io.EOF is not an error if some bytes were read
return nil, fmt.Errorf("text snippet: failed to read from original file '%s': %w", file.RealPath, readErr)
logger.Errorf("text snippet preview: failed to read file '%s': %v", file.Name, readErr)
return nil, fmt.Errorf("text snippet: failed to read file")
}
if n == 0 {
return nil, fmt.Errorf("text snippet: original file '%s' is empty or unreadable", file.RealPath)
return nil, fmt.Errorf("text snippet: file is empty or unreadable")
} else {
tempFile, err := os.Create(tempFilePath)
if err != nil {
return nil, fmt.Errorf("text snippet: failed to create temporary file '%s': %w", tempFilePath, err)
logger.Errorf("text snippet preview: failed to create temp file for '%s': %v", file.Name, err)
return nil, fmt.Errorf("text snippet: failed to create temporary file")
}
defer os.Remove(tempFilePath) // Ensure cleanup on error
// Write the read content (up to 1KB or EOF) to the temporary file
if _, err := tempFile.Write(buffer[:n]); err != nil {
tempFile.Close() // Attempt to close
os.Remove(tempFilePath) // Clean up on error
return nil, fmt.Errorf("text snippet: failed to write to temporary file '%s': %w", tempFilePath, err)
logger.Errorf("text snippet preview: failed to write temp file for '%s': %v", file.Name, err)
return nil, fmt.Errorf("text snippet: failed to write temporary file")
}
// Close the temporary file so it can be reliably opened by path by other processes/functions
if err := tempFile.Close(); err != nil {
os.Remove(tempFilePath) // Clean up on error
return nil, fmt.Errorf("text snippet: failed to close temporary file '%s': %w", tempFilePath, err)
logger.Errorf("text snippet preview: failed to close temp file for '%s': %v", file.Name, err)
return nil, fmt.Errorf("text snippet: failed to close temporary file")
}
docPath = tempFilePath // Update docPath to point to the new temporary text snippet file
}
docPath = tempFilePath // Update docPath to point to the new temporary text snippet file
}
}
// Check timeout before opening document
timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
}
doc, err := fitz.New(docPath) // This calls the CGo version
imageBytes, err := s.renderDocPageJPEG(timeoutCtx, docPath, pageNumber, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to open PDF from memory for file '%s': %w", docPath, err)
logger.Errorf("document preview render failed for '%s': %v", file.Name, err)
return nil, fmt.Errorf("failed to create document preview")
}
defer doc.Close()
// Check timeout after opening document
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
}
// Get the image from the doc page
numPages := doc.NumPage()
if pageNumber < 0 || pageNumber >= numPages {
return nil, fmt.Errorf("invalid page number %d for PDF with %d pages ('%s')", pageNumber, numPages, docPath)
}
img, err := doc.Image(pageNumber)
if err != nil {
return nil, fmt.Errorf("failed to get image from page %d of '%s': %w", pageNumber, docPath, err)
}
// Check timeout after rendering image
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
}
// Create a new buffer to hold the image bytes
var buf bytes.Buffer
// Encode the image directly into the buffer
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpeg.DefaultQuality})
if err != nil {
return nil, fmt.Errorf("failed to encode image to jpeg for '%s': %w", docPath, err)
}
// Return the byte slice from the buffer
return buf.Bytes(), nil
return imageBytes, nil
}
+2 -1
View File
@@ -14,7 +14,8 @@ func docEnabled() bool {
return false
}
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int, previewSize string) ([]byte, error) {
_ = previewSize
// Reference it to prevent unused field warning when building without mupdf
_ = &s.docGenMutex
return nil, nil
+7
View File
@@ -0,0 +1,7 @@
//go:build mupdf
package preview
// Link MuPDF static libraries through go-fitz so preview CGO can call fz_* without
// duplicating platform-specific LDFLAGS in this package.
import _ "github.com/gen2brain/go-fitz"
+272
View File
@@ -0,0 +1,272 @@
//go:build mupdf
package preview
/*
#cgo CFLAGS: -I${SRCDIR}/gofitzinclude
#include <mupdf/fitz.h>
#include <stdlib.h>
#include <string.h>
static void fb_silence_warnings(void *user, const char *message) {
(void)user;
(void)message;
}
static double fb_dmin(double a, double b) { return a < b ? a : b; }
static double fb_dmax(double a, double b) { return a > b ? a : b; }
// fb_render_document_page_jpeg renders one page to JPEG bytes.
// max_width/max_height <= 0 selects original mode (~150 DPI). Otherwise renders into that box.
// resize_mode_fill: 0 = fit (no upscale), 1 = fill (center crop to exact box).
// Returns 0 on success; on failure writes a message to err_buf and returns -1.
// out is malloc'd; caller must free.
static int fb_render_document_page_jpeg(
const char *path,
int page_num,
int max_width,
int max_height,
int resize_mode_fill,
int jpeg_quality,
unsigned char **out,
size_t *out_len,
char *err_buf,
size_t err_buf_len
) {
fz_context *ctx = NULL;
fz_document *doc = NULL;
fz_page *page = NULL;
fz_pixmap *pixmap = NULL;
fz_device *device = NULL;
fz_buffer *buf = NULL;
unsigned char *jpeg_data = NULL;
size_t jpeg_len = 0;
int rc = -1;
if (!path || !out || !out_len || !err_buf || err_buf_len == 0) {
return -1;
}
*out = NULL;
*out_len = 0;
memset(err_buf, 0, err_buf_len);
ctx = fz_new_context_imp(NULL, NULL, 256 << 20, FZ_VERSION);
if (!ctx) {
snprintf(err_buf, err_buf_len, "cannot create mupdf context");
return -1;
}
fz_set_warning_callback(ctx, fb_silence_warnings, NULL);
fz_var(doc);
fz_var(page);
fz_var(pixmap);
fz_var(device);
fz_var(buf);
fz_var(jpeg_data);
fz_var(jpeg_len);
fz_var(rc);
fz_try(ctx) {
fz_register_document_handlers(ctx);
doc = fz_open_document(ctx, path);
if (fz_needs_password(ctx, doc)) {
fz_throw(ctx, FZ_ERROR_ARGUMENT, "document needs password");
}
int num_pages = fz_count_pages(ctx, doc);
if (page_num < 0 || page_num >= num_pages) {
fz_throw(ctx, FZ_ERROR_ARGUMENT, "invalid page number");
}
page = fz_load_page(ctx, doc, page_num);
fz_rect bounds = fz_bound_page(ctx, page);
fz_matrix ctm;
fz_irect bbox;
if (max_width <= 0 || max_height <= 0) {
double dpi = 150.0;
ctm = fz_scale(dpi / 72.0, dpi / 72.0);
bounds = fz_transform_rect(bounds, ctm);
bbox = fz_round_rect(bounds);
} else {
double page_w = bounds.x1 - bounds.x0;
double page_h = bounds.y1 - bounds.y0;
if (page_w <= 0 || page_h <= 0) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid page bounds");
}
if (resize_mode_fill) {
double scale = fb_dmax((double)max_width / page_w, (double)max_height / page_h);
double tx = ((double)max_width - page_w * scale) / 2.0 - bounds.x0 * scale;
double ty = ((double)max_height - page_h * scale) / 2.0 - bounds.y0 * scale;
ctm = fz_concat(
fz_translate(tx, ty),
fz_concat(fz_scale(scale, scale), fz_translate(-bounds.x0, -bounds.y0))
);
bbox = fz_make_irect(0, 0, max_width, max_height);
} else {
double scale = fb_dmin((double)max_width / page_w, (double)max_height / page_h);
if (scale > 1.0) {
scale = 1.0;
}
ctm = fz_concat(fz_scale(scale, scale), fz_translate(-bounds.x0, -bounds.y0));
bounds = fz_transform_rect(bounds, ctm);
bbox = fz_round_rect(bounds);
}
}
pixmap = fz_new_pixmap_with_bbox(ctx, fz_device_rgb(ctx), bbox, NULL, 0);
fz_clear_pixmap_with_value(ctx, pixmap, 0xff);
device = fz_new_draw_device(ctx, ctm, pixmap);
fz_enable_device_hints(ctx, device, FZ_NO_CACHE);
fz_run_page_contents(ctx, page, device, fz_identity, NULL);
fz_close_device(ctx, device);
fz_drop_device(ctx, device);
device = NULL;
buf = fz_new_buffer_from_pixmap_as_jpeg(ctx, pixmap, fz_default_color_params, jpeg_quality, 0);
jpeg_len = fz_buffer_storage(ctx, buf, &jpeg_data);
if (jpeg_len == 0 || jpeg_data == NULL) {
fz_throw(ctx, FZ_ERROR_GENERIC, "empty jpeg buffer");
}
*out = (unsigned char *)malloc(jpeg_len);
if (*out == NULL) {
fz_throw(ctx, FZ_ERROR_SYSTEM, "malloc failed");
}
memcpy(*out, jpeg_data, jpeg_len);
*out_len = jpeg_len;
rc = 0;
}
fz_always(ctx) {
if (device) {
fz_drop_device(ctx, device);
}
if (pixmap) {
fz_drop_pixmap(ctx, pixmap);
}
if (page) {
fz_drop_page(ctx, page);
}
if (doc) {
fz_drop_document(ctx, doc);
}
if (buf) {
fz_drop_buffer(ctx, buf);
}
}
fz_catch(ctx) {
const char *msg = fz_caught_message(ctx);
if (msg && msg[0] != '\0') {
snprintf(err_buf, err_buf_len, "%s", msg);
} else {
snprintf(err_buf, err_buf_len, "mupdf render failed");
}
rc = -1;
}
fz_drop_context(ctx);
if (rc != 0 && *out != NULL) {
free(*out);
*out = NULL;
*out_len = 0;
}
return rc;
}
*/
import "C"
import (
"context"
"fmt"
"runtime"
"strings"
"unsafe"
)
type docRenderParams struct {
maxWidth int
maxHeight int
fill bool
jpegQuality int
}
func docRenderParamsForPreviewSize(previewSize string) (docRenderParams, error) {
if previewSize == "original" {
return docRenderParams{jpegQuality: QualityHigh.jpegQuality()}, nil
}
opts, err := getPreviewOptions(previewSize)
if err != nil {
return docRenderParams{}, err
}
return docRenderParams{
maxWidth: opts.Width,
maxHeight: opts.Height,
fill: opts.ResizeMode == ResizeModeFill,
jpegQuality: opts.Quality.jpegQuality(),
}, nil
}
func (s *Service) renderDocPageJPEG(ctx context.Context, path string, pageNumber int, previewSize string) ([]byte, error) {
params, err := docRenderParamsForPreviewSize(previewSize)
if err != nil {
return nil, err
}
cPath := C.CString(path)
defer C.free(unsafe.Pointer(cPath))
var out *C.uchar
var outLen C.size_t
errBuf := make([]byte, 512)
fillMode := 0
if params.fill {
fillMode = 1
}
// MuPDF is not safe for concurrent CGO use; serialize only this call.
s.docGenMutex.Lock()
if err := ctx.Err(); err != nil {
s.docGenMutex.Unlock()
return nil, err
}
runtime.LockOSThread()
rc := C.fb_render_document_page_jpeg(
cPath,
C.int(pageNumber),
C.int(params.maxWidth),
C.int(params.maxHeight),
C.int(fillMode),
C.int(params.jpegQuality),
&out,
&outLen,
(*C.char)(unsafe.Pointer(&errBuf[0])),
C.size_t(len(errBuf)),
)
runtime.UnlockOSThread()
s.docGenMutex.Unlock()
if rc != 0 {
msg := strings.TrimRight(string(errBuf), "\x00")
if msg == "" {
msg = "mupdf render failed"
}
return nil, fmt.Errorf("%s", msg)
}
if out == nil || outLen == 0 {
return nil, fmt.Errorf("mupdf render returned empty jpeg")
}
defer C.free(unsafe.Pointer(out))
return C.GoBytes(unsafe.Pointer(out), C.int(outLen)), nil
}
+45 -21
View File
@@ -17,10 +17,10 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/imagemeta"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/imagemeta"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -261,7 +261,7 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
switch previewType {
case previewTypeDocument:
return s.generateDocumentPreview(ctx, file, hash)
return s.generateDocumentPreview(ctx, file, previewSize, hash)
case previewTypeOffice:
return s.generateOfficeFilePreview(ctx, file, officeUrl)
@@ -270,6 +270,19 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
return s.generateHEICPreview(ctx, file, previewSize)
case previewTypeImage:
ext := strings.ToLower(filepath.Ext(file.Name))
if iteminfo.IsRawImage(ext) {
if s.ffmpegService != nil {
previewBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err == nil && len(previewBytes) >= minPreviewSize {
return previewBytes, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
return nil, ErrUnsupportedFormat
}
return s.generateImagePreview(ctx, file, previewSize)
case previewTypeVideo:
@@ -290,9 +303,9 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
}
// generateDocumentPreview generates preview for PDF and document files
func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, hash string) ([]byte, error) {
func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, hash string) ([]byte, error) {
tempFilePath := filepath.Join(s.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0, previewSize) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
@@ -364,6 +377,13 @@ func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.Extend
return nil, err
}
ext := strings.ToLower(filepath.Ext(file.Name))
format, err := s.FormatFromExtension(ext)
if err != nil {
return nil, err
}
options.Format = format
imageBytes, err := s.CreatePreview(ctx, f, file.Size, options)
if err != nil {
if ctx.Err() != nil {
@@ -415,23 +435,26 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, ctx.Err()
}
// Acquire global image processor semaphore for ALL operations
const largeFileSizeThreshold = 8 * 1024 * 1024 // 8MB
if file.Size >= largeFileSizeThreshold && service.imageLargeSem != nil {
if err := service.acquireImageLargeSem(ctx); err != nil {
return nil, err
// MuPDF document previews render at target size in CGO; they only need docGenMutex.
previewTypeEarly := determinePreviewType(file)
skipImageSem := previewTypeEarly == previewTypeDocument && settings.Env.MuPdfAvailable
if !skipImageSem {
const largeFileSizeThreshold = 8 * 1024 * 1024 // 8MB
if file.Size >= largeFileSizeThreshold && service.imageLargeSem != nil {
if err := service.acquireImageLargeSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageLargeSem()
} else {
if err := service.acquireImageSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageSem()
}
defer service.releaseImageLargeSem()
} else {
if err := service.acquireImageSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageSem()
}
// Check if context is cancelled after acquiring semaphore
if ctx.Err() != nil {
return nil, ctx.Err()
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
// Enforce file size limit for image preview generation to prevent memory exhaustion
@@ -482,7 +505,8 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// When we got bytes from an embedded preview, we still need to resize to small/large/xlarge (original is never converted).
// When we got bytes from type-specific path, regular images are already resized; others need resize below.
previewType := determinePreviewType(file)
if !fromEmbeddedPreview && previewType == previewTypeImage {
if !fromEmbeddedPreview && (previewType == previewTypeImage ||
(previewType == previewTypeDocument && settings.Env.MuPdfAvailable)) {
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
}
@@ -0,0 +1,119 @@
package preview
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
func TestGenerateRawPreviewRawWithoutEmbeddedReturnsUnsupported(t *testing.T) {
data := make([]byte, 256)
copy(data[:16], []byte("FUJIFILMCCD-RAW "))
dir := t.TempDir()
path := filepath.Join(dir, "empty.raf")
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
svc := NewPreviewGenerator(1, dir)
file := iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "empty.raf",
Size: int64(len(data)),
ModTime: time.Now(),
Type: "image/x-fuji-raf",
},
},
RealPath: path,
}
_, err := svc.generateRawPreview(context.Background(), file, "small", "", 0, "hash")
if !errors.Is(err, ErrUnsupportedFormat) {
t.Fatalf("generateRawPreview() err = %v, want ErrUnsupportedFormat", err)
}
if strings.Contains(err.Error(), "SOI") {
t.Fatalf("generateRawPreview() should fail fast, got JPEG decode error: %v", err)
}
}
func TestGenerateImagePreviewUnsupportedExtensionReturnsUnsupported(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "clip.mov")
// Must exceed maxSizeForOriginal (256KB) so we don't short-circuit to raw bytes.
payload := make([]byte, 256*1024+1)
copy(payload, []byte("not a video"))
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatal(err)
}
svc := NewPreviewGenerator(1, dir)
file := iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "clip.mov",
Size: int64(len(payload)),
ModTime: time.Now(),
Type: "video/quicktime",
},
},
RealPath: path,
}
_, err := svc.generateImagePreview(context.Background(), file, "small")
if !errors.Is(err, ErrUnsupportedFormat) {
t.Fatalf("generateImagePreview() err = %v, want ErrUnsupportedFormat", err)
}
}
func TestGenerateImagePreviewUsesPNGFormat(t *testing.T) {
dir := t.TempDir()
svc := NewPreviewGenerator(1, dir)
// 1x1 red PNG; pad past maxSizeForOriginal (256KB) so we exercise format selection.
const maxSizeForOriginal = 256 * 1024
pngBytes := []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
0x44, 0xae, 0x42, 0x60, 0x82,
}
payload := make([]byte, maxSizeForOriginal+1)
copy(payload, pngBytes)
path := filepath.Join(dir, "pixel.png")
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatal(err)
}
file := iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "pixel.png",
Size: int64(len(payload)),
ModTime: time.Now(),
Type: "image/png",
},
},
RealPath: path,
}
got, err := svc.generateImagePreview(context.Background(), file, "small")
if err != nil {
t.Fatal(err)
}
if len(got) < 8 || string(got[:8]) != "\x89PNG\r\n\x1a\n" {
t.Fatalf("expected PNG signature, got %x", got[:min(8, len(got))])
}
}
+3 -1
View File
@@ -49,7 +49,9 @@ func initialize(dbPath string) (bool, error) {
defer sharesMux.Unlock()
defer indexMux.Unlock()
logger.Info("Initializing state management system...")
if !settings.Env.IsCLIMode {
logger.Info("Initializing state management system...")
}
var existingDb bool
var err error
sqlDb, existingDb, err = sqldb.NewSQLStore(dbPath)
+22
View File
@@ -10,6 +10,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
)
// User operations
@@ -221,6 +222,10 @@ func CreateUser(user *users.User, plaintextPassword string) error {
users.SyncBackendSourcePermissionsMap(user)
if links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes); changed {
user.SidebarLinks = links
}
usersMux.Lock()
defer usersMux.Unlock()
@@ -328,6 +333,19 @@ func UpdateUser(user *users.User, plaintextPassword string, fields ...string) er
}
}
if sidebarFieldsPatched(fields) {
var links []users.SidebarLink
var changed bool
if fieldListRequiresScopeConversion(fields) {
links, changed = usersidebar.PrepareSidebarLinksForPersist(existingUser.SidebarLinks, existingUser.BackendScopes)
} else {
links, changed = usersidebar.NormalizeSidebarLinks(existingUser.SidebarLinks)
}
if changed {
existingUser.SidebarLinks = links
}
}
return commitUserUpdate(existingUser, storedSnapshot, sourceDefaults, sourceEnforced)
}
@@ -390,6 +408,10 @@ func fieldListRequiresScopeConversion(fields []string) bool {
return fieldListPatchesBackendScopes(fields) || fieldListPatchesAPISourcePermissions(fields)
}
func sidebarFieldsPatched(fields []string) bool {
return FieldListIncludes(fields, "sidebarLinks") || fieldListRequiresScopeConversion(fields)
}
// applyScopesFromAPI converts API scopes (with nested permissions) into BackendScopes.
// Legacy sourcePermissions map is merged into scopes when permissions are omitted per scope.
func applyScopesFromAPI(user *users.User) error {
@@ -0,0 +1,181 @@
package state
import (
"path/filepath"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func initSidebarLinkTestDB(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize("../../../_docker/src/noauth/backend/config.yaml")
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
if _, err := Initialize(dbPath); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = Close() })
}
func countSourceSidebarLinks(links []users.SidebarLink) int {
n := 0
for _, link := range links {
if strings.HasPrefix(link.Category, "source") {
n++
}
}
return n
}
func TestCreateUserAddsSidebarLinksForAllScopes(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "scope-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
{Name: "include", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("scope-user")
if err != nil {
t.Fatal(err)
}
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
t.Fatalf("source sidebar links = %d, want 2: %#v", count, loaded.SidebarLinks)
}
}
func TestUpdateUserScopesAddsMissingSidebarLink(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "patch-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
patch := &users.User{
ID: u.ID,
FrontendUser: users.FrontendUser{
Username: "patch-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
{Name: "include", Scope: "/"},
},
},
}
if err := UpdateUser(patch, "", "scopes"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("patch-user")
if err != nil {
t.Fatal(err)
}
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
t.Fatalf("source sidebar links = %d, want 2: %#v", count, loaded.SidebarLinks)
}
}
func TestUpdateUserScopesPrunesSidebarLinks(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "stale-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
{Name: "include", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
patch := &users.User{
ID: u.ID,
FrontendUser: users.FrontendUser{
Username: "stale-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
},
},
}
if err := UpdateUser(patch, "", "scopes"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("stale-user")
if err != nil {
t.Fatal(err)
}
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 1 {
t.Fatalf("expected revoked include link pruned, got %d links: %#v", count, loaded.SidebarLinks)
}
}
func TestUpdateUserSidebarLinksPreservesFolderShortcuts(t *testing.T) {
initSidebarLinkTestDB(t)
u := &users.User{
FrontendUser: users.FrontendUser{
Username: "folder-user",
FrontendScopes: []users.FrontendScope{
{Name: "exclude", Scope: "/"},
},
},
}
if err := CreateUser(u, "password"); err != nil {
t.Fatal(err)
}
links := []users.SidebarLink{
{Name: "exclude", Category: "source", SourceName: "exclude", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: "exclude", Target: "/photos"},
{Name: "divider", Category: "divider", Target: "#"},
}
patch := &users.User{
ID: u.ID,
FrontendUser: users.FrontendUser{
Username: "folder-user",
NonAdminEditable: users.NonAdminEditable{
SidebarLinks: links,
},
},
}
if err := UpdateUser(patch, "", "sidebarLinks"); err != nil {
t.Fatal(err)
}
loaded, err := GetUserByUsername("folder-user")
if err != nil {
t.Fatal(err)
}
if len(loaded.SidebarLinks) != 3 {
t.Fatalf("len(sidebarLinks) = %d, want 3: %#v", len(loaded.SidebarLinks), loaded.SidebarLinks)
}
if loaded.SidebarLinks[1].Name != "Photos" || loaded.SidebarLinks[1].Icon != "photo" || loaded.SidebarLinks[1].Target != "/photos" {
t.Fatalf("folder shortcut not preserved: %#v", loaded.SidebarLinks[1])
}
if !strings.Contains(loaded.SidebarLinks[0].SourceName, "playwright-files") {
t.Fatalf("expected canonical path persisted, got SourceName=%q", loaded.SidebarLinks[0].SourceName)
}
}
+3
View File
@@ -28,6 +28,9 @@ func FrontendLinks(links []users.SidebarLink, showToolsInSidebar bool) []users.S
}
}
link.SourceName = source.Name
if link.Name == "" {
link.Name = source.Name
}
} else if link.Category == "tool" && link.Target == "/tools" {
hasTools = true
}
@@ -0,0 +1,27 @@
package usersidebar
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestFrontendLinks_populatesEmptySourceName(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Category: "source", SourceName: ".", Target: "/"},
{Name: "My Vault", Category: string(users.SidebarLinkSourceMinimal), Icon: "folder", SourceName: "../frontend/tests/playwright-files", Target: "/docs"},
}
out := FrontendLinks(in, false)
if len(out) != 2 {
t.Fatalf("len(out) = %d, want 2", len(out))
}
if out[0].Name != "docker" || out[0].SourceName != "docker" {
t.Fatalf("default source link = %#v", out[0])
}
if out[1].Name != "My Vault" || out[1].SourceName != "playwright + files" {
t.Fatalf("custom source link = %#v", out[1])
}
}
+36 -13
View File
@@ -8,9 +8,12 @@ import (
// NormalizeSidebarLinks canonicalizes persisted sidebar links for storage.
// Source links are resolved via ResolveSourceKey on SourceName, with Name as fallback.
// SourceName is set to the canonical backend path; Name to the configured display name.
// Unresolvable source links are dropped. Source links are deduped by canonical path
// (first occurrence wins). Non-source links pass through; duplicate Tools links are deduped.
// SourceName is set to the canonical backend path; custom Name, Icon, and Category are preserved.
// Empty Name means "use live source display name"; names matching the current source display name
// are blanked so renames propagate without overwriting custom labels.
// Unresolvable source links are dropped. Source links are deduped by canonical path plus target
// (first occurrence wins), so multiple folder shortcuts on one source are kept. Non-source links
// pass through; duplicate Tools links are deduped.
func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool) {
if !users.SourceConfigLoaded() {
return links, false
@@ -19,7 +22,7 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
return links, false
}
seenSourcePaths := make(map[string]struct{})
seenSourceKeys := make(map[sourceLinkDedupeKey]struct{})
hasTools := false
out := make([]users.SidebarLink, 0, len(links))
changed := false
@@ -31,21 +34,22 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
changed = true
continue
}
if _, dup := seenSourcePaths[source.Path]; dup {
changed = true
continue
}
seenSourcePaths[source.Path] = struct{}{}
normalized := link
normalized.Category = users.NormalizeSidebarLinkCategory(normalized.Category)
normalized.SourceName = source.Path
if strings.TrimSpace(normalized.Name) == "" {
normalized.Name = source.Name
if strings.TrimSpace(normalized.Name) == source.Name {
normalized.Name = ""
}
if normalized.Target == "" {
normalized.Target = "/"
normalized.Target = normalizeSidebarTarget(normalized.Target)
key := sourceLinkDedupeKey{sourcePath: source.Path, target: normalized.Target}
if _, dup := seenSourceKeys[key]; dup {
changed = true
continue
}
seenSourceKeys[key] = struct{}{}
if normalized != link {
changed = true
}
@@ -77,3 +81,22 @@ func resolveSourceLink(link users.SidebarLink) (users.SourceInfo, bool) {
}
return users.SourceInfo{}, false
}
func normalizeSidebarTarget(target string) string {
t := strings.TrimSpace(target)
if t == "" || t == "#" {
return "/"
}
if !strings.HasPrefix(t, "/") {
t = "/" + t
}
if t != "/" && strings.HasSuffix(t, "/") {
t = strings.TrimSuffix(t, "/")
}
return t
}
type sourceLinkDedupeKey struct {
sourcePath string
target string
}
+115 -12
View File
@@ -69,9 +69,9 @@ func TestNormalizeSidebarLinks_dedupesLinuxAndDockerPaths(t *testing.T) {
t.Fatalf("len(out) = %d, want 3", len(out))
}
want := []users.SidebarLink{
{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: "/"},
{Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
{Category: "source", SourceName: ".", Target: "/"},
{Category: "source", SourceName: "/tests/playwright-files", Target: "/"},
}
if !reflect.DeepEqual(out, want) {
t.Fatalf("got %#v, want %#v", out, want)
@@ -95,8 +95,24 @@ func TestNormalizeSidebarLinks_nameFallbackRemapsStalePath(t *testing.T) {
if out[0].SourceName != "../frontend/tests/playwright-files" {
t.Fatalf("SourceName = %q", out[0].SourceName)
}
if out[0].Name != "playwright + files" {
t.Fatalf("Name = %q", out[0].Name)
if out[0].Name != "" {
t.Fatalf("Name = %q, want blanked default name", out[0].Name)
}
}
func TestNormalizeSidebarLinks_blanksDefaultSourceName(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
}
out, changed := NormalizeSidebarLinks(in)
if !changed {
t.Fatal("expected changed=true")
}
if out[0].Name != "" {
t.Fatalf("Name = %q, want blanked when matching source display name", out[0].Name)
}
}
@@ -110,18 +126,20 @@ func TestNormalizeSidebarLinks_idempotentOnCanonicalLinks(t *testing.T) {
}
out, changed := NormalizeSidebarLinks(in)
if changed {
t.Fatal("expected changed=false for canonical links")
if !changed {
t.Fatal("expected changed=true when blanking default names")
}
if !reflect.DeepEqual(out, in) {
t.Fatalf("got %#v, want %#v", out, in)
for _, link := range out {
if link.Name != "" {
t.Fatalf("expected blank default name, got %#v", link)
}
}
out2, changed2 := NormalizeSidebarLinks(out)
if changed2 {
t.Fatal("expected second pass unchanged")
}
if !reflect.DeepEqual(out2, in) {
if !reflect.DeepEqual(out2, out) {
t.Fatalf("second pass got %#v", out2)
}
}
@@ -141,8 +159,8 @@ func TestNormalizeSidebarLinks_dropsUnresolvableSourceLink(t *testing.T) {
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
}
if out[0].Name != "docker" {
t.Fatalf("remaining link = %#v", out[0])
if out[0].Name != "" {
t.Fatalf("remaining link should have blanked default name: %#v", out[0])
}
}
@@ -164,6 +182,91 @@ func TestNormalizeSidebarLinks_dedupesToolsLink(t *testing.T) {
}
}
func TestNormalizeSidebarLinks_preservesMultipleFolderShortcutsOnSameSource(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
{Name: "Docs", Category: string(users.SidebarLinkSourceAlt), Icon: "folder", SourceName: ".", Target: "/docs"},
{Name: "divider", Category: "divider", Target: "#"},
}
out, changed := NormalizeSidebarLinks(in)
if !changed {
t.Fatal("expected changed=true when blanking default root source name")
}
if len(out) != 4 {
t.Fatalf("len(out) = %d, want 4", len(out))
}
if out[0].Name != "" {
t.Fatalf("root source link should have blank default name: %#v", out[0])
}
if out[1].Name != "Photos" || out[1].Icon != "photo" || out[1].Target != "/photos" {
t.Fatalf("photos link = %#v", out[1])
}
if out[2].Category != string(users.SidebarLinkSourceAlt) {
t.Fatalf("docs category = %q", out[2].Category)
}
}
func TestNormalizeSidebarLinks_dedupesSameSourceAndTarget(t *testing.T) {
testSourceConfig(t)
in := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "docker root", Category: "source-minimal", Icon: "home", SourceName: "docker", Target: "/"},
}
out, changed := NormalizeSidebarLinks(in)
if !changed {
t.Fatal("expected changed=true")
}
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
}
if out[0].Name != "" {
t.Fatalf("first link wins with blanked default name: %#v", out[0])
}
}
func TestNormalizeSidebarLinks_noCollisionOnPipeInPathOrTarget(t *testing.T) {
had := users.SourceConfigLoaded()
t.Cleanup(func() {
if !had {
users.SetSourceConfig(nil)
}
})
users.SetSourceConfig(&users.SourceConfigProvider{
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
switch path {
case "a":
return users.SourceInfo{Path: "a", Name: "src-a"}, true
case "a|/b":
return users.SourceInfo{Path: "a|/b", Name: "src-pipe"}, true
default:
return users.SourceInfo{}, false
}
},
GetSourceByName: func(name string) (users.SourceInfo, bool) {
return users.SourceInfo{}, false
},
})
in := []users.SidebarLink{
{Name: "one", Category: "source", SourceName: "a", Target: "/b|/c"},
{Name: "two", Category: "source", SourceName: "a|/b", Target: "/c"},
}
out, changed := NormalizeSidebarLinks(in)
if changed {
t.Fatal("expected changed=false for distinct path/target pairs")
}
if len(out) != 2 {
t.Fatalf("len(out) = %d, want 2 (no false dedupe)", len(out))
}
}
func TestNormalizeSidebarLinks_noConfigLoaded(t *testing.T) {
had := users.SourceConfigLoaded()
t.Cleanup(func() {
+33
View File
@@ -0,0 +1,33 @@
package usersidebar
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
// PrepareSidebarLinksForPersist normalizes sidebar links, prunes links for revoked scopes,
// and adds missing scoped source entries. Sources absent from config are dropped by NormalizeSidebarLinks.
func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
updated := false
if normalized, changed := NormalizeSidebarLinks(links); changed {
links = normalized
updated = true
}
if pruned, changed := PruneSidebarLinksForScopes(links, scopes); changed {
links = pruned
updated = true
}
merged, changed := EnsureSidebarLinksFromScopes(links, scopes)
if changed {
links = merged
updated = true
if normalized, changed := NormalizeSidebarLinks(links); changed {
links = normalized
updated = true
}
}
return links, updated
}
@@ -0,0 +1,55 @@
package usersidebar
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestPrepareSidebarLinksForPersist_addsScopedSourcesAndPreservesFolders(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
}
scopes := []users.BackendScope{
{Path: "."},
{Path: "../frontend/tests/playwright-files"},
}
out, changed := PrepareSidebarLinksForPersist(links, scopes)
if !changed {
t.Fatal("expected changed=true")
}
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3", len(out))
}
if out[1].Name != "Photos" || out[1].Icon != "photo" {
t.Fatalf("folder shortcut lost: %#v", out[1])
}
if out[2].Name != "" {
t.Fatalf("added scope link should have empty default name, got %#v", out[2])
}
}
func TestPrepareSidebarLinksForPersist_prunesLinksWhenScopeRemoved(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
}
scopes := []users.BackendScope{
{Path: "."},
}
out, changed := PrepareSidebarLinksForPersist(links, scopes)
if !changed {
t.Fatal("expected changed=true when revoked scope link is pruned")
}
if len(out) != 2 {
t.Fatalf("len(out) = %d, want 2 (revoked scope link removed)", len(out))
}
}
+37
View File
@@ -0,0 +1,37 @@
package usersidebar
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
// PruneSidebarLinksForScopes removes source-type links whose source is not in the user's scopes.
// Non-source links (tool, custom, divider) are preserved.
func PruneSidebarLinksForScopes(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
if !users.SourceConfigLoaded() || len(scopes) == 0 {
return links, false
}
allowed := make(map[string]struct{}, len(scopes))
for _, path := range uniqueScopedSourcePaths(scopes) {
allowed[path] = struct{}{}
}
out := make([]users.SidebarLink, 0, len(links))
changed := false
for _, link := range links {
if users.IsSourceSidebarCategory(link.Category) {
source, ok := resolveSourceLink(link)
if !ok {
changed = true
continue
}
if _, ok := allowed[source.Path]; !ok {
changed = true
continue
}
}
out = append(out, link)
}
return out, changed
}
@@ -0,0 +1,51 @@
package usersidebar
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestPruneSidebarLinksForScopes_removesRevokedSourceAndFolderShortcut(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
{Name: "Docs", Category: string(users.SidebarLinkCustom), Target: "https://example.com", Icon: "link"},
}
scopes := []users.BackendScope{
{Path: "."},
}
out, changed := PruneSidebarLinksForScopes(links, scopes)
if !changed {
t.Fatal("expected changed=true")
}
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3 (playwright link removed, docker + folder + custom kept)", len(out))
}
if out[2].Category != string(users.SidebarLinkCustom) {
t.Fatalf("custom link lost: %#v", out[2])
}
}
func TestPruneSidebarLinksForScopes_noChangeWhenAllScoped(t *testing.T) {
testSourceConfig(t)
links := []users.SidebarLink{
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
}
scopes := []users.BackendScope{
{Path: "."},
}
out, changed := PruneSidebarLinksForScopes(links, scopes)
if changed {
t.Fatal("expected changed=false")
}
if len(out) != 1 {
t.Fatalf("len(out) = %d, want 1", len(out))
}
}
-1
View File
@@ -26,7 +26,6 @@ func EnsureSidebarLinksFromScopes(links []users.SidebarLink, scopes []users.Back
continue
}
out = append(out, users.SidebarLink{
Name: source.Name,
Category: string(users.SidebarLinkSource),
Target: "/",
SourceName: source.Path,
+2 -2
View File
@@ -67,8 +67,8 @@ func TestEnsureSidebarLinksFromScopes_addsMissingScopedSources(t *testing.T) {
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3", len(out))
}
if out[2].Name != "playwright + files" {
t.Fatalf("added link = %#v", out[2])
if out[2].Name != "" {
t.Fatalf("added link should have empty default name, got %#v", out[2])
}
if out[1].Category != string(users.SidebarLinkCustom) {
t.Fatalf("custom link lost: %#v", out[1])
+1
View File
@@ -14,4 +14,5 @@ var (
JwtCache = cache.NewCache[string](1*time.Hour, 72*time.Hour)
ViewGrantsCache = cache.NewCache[ViewGrant](15 * time.Minute)
ViewGrantIndex = cache.NewCache[string](15 * time.Minute)
ShareAccessGrantsCache = cache.NewCache[ShareAccessGrant](24 * time.Hour)
)
+52
View File
@@ -1,12 +1,14 @@
package utils
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
)
@@ -56,6 +58,37 @@ func HashSHA256(data string) string {
return hex.EncodeToString(bytes[:])
}
// SignHMACSHA256Base64URL returns base64url(payload).base64url(hmac-sha256 signature).
func SignHMACSHA256Base64URL(key, payload []byte) string {
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payloadB64))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return payloadB64 + "." + signature
}
// VerifyHMACSHA256Base64URL validates token form and returns the decoded payload.
func VerifyHMACSHA256Base64URL(key []byte, token string) ([]byte, bool) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return nil, false
}
payloadB64, signature := parts[0], parts[1]
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payloadB64))
expectedSignature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
return nil, false
}
payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return nil, false
}
return payload, true
}
// RandomUint64ID returns a non-zero cryptographically random uint64 (e.g. new user rows after migration).
// Small ids 1, 2, 3, … remain valid for migrated Bolt-era users.
func RandomUint64ID() (uint64, error) {
@@ -89,3 +122,22 @@ func CSPNonce() (string, error) {
}
return base64.StdEncoding.EncodeToString(b), nil
}
// CSPNonceFromData returns an existing cspNonce from map template data, or generates
// and stores one when data is a map[string]interface{}. For other data types it
// generates a fresh nonce without modifying data.
func CSPNonceFromData(data interface{}) (string, error) {
m, ok := data.(map[string]interface{})
if !ok {
return CSPNonce()
}
if nonce, ok := m["cspNonce"].(string); ok && nonce != "" {
return nonce, nil
}
nonce, err := CSPNonce()
if err != nil {
return "", fmt.Errorf("csp nonce: %w", err)
}
m["cspNonce"] = nonce
return nonce, nil
}
+1
View File
@@ -69,6 +69,7 @@ type FileOptions struct {
// SanitizePath cleans and validates a path or path-like filter (index paths, globs, etc.)
// to block traversal. Rule 1: Do Not Use Untrusted Input in File Paths (without validation).
func SanitizePath(inputPath string) (string, error) {
inputPath = strings.ReplaceAll(inputPath, `\`, `/`)
clean := path.Clean(inputPath)
// Split the path into segments to check for path traversal attempts.
-4
View File
@@ -34,10 +34,6 @@ func ParseSanitizedIndexPath(userPath string, isDir bool) (IndexPath, error) {
}
func parseIndexPath(path string, isDir bool, strict bool) (IndexPath, error) {
if strict && strings.Contains(path, `\`) {
return IndexPath{}, fmt.Errorf("invalid path: backslashes not allowed")
}
inner := strings.Trim(path, "/")
if inner == "" {
return IndexPath{IsDir: isDir}, nil
+11
View File
@@ -53,6 +53,9 @@ func TestSanitizePath_indexPaths(t *testing.T) {
{"traversal segment", "..", "", true},
{"resolved traversal", "/../secret", "/secret", false},
{"empty", "", "", true},
{"backslash traversal", `..\secret`, "", true},
{"backslash double traversal", `..\..\secret`, "", true},
{"backslash path normalized", `\foo\bar`, "/foo/bar", false},
}
for _, tt := range tests {
@@ -109,6 +112,14 @@ func TestParseSanitizedIndexPath(t *testing.T) {
if got.String() != "/secret/" {
t.Errorf("got %q want /secret/", got.String())
}
got, err = ParseSanitizedIndexPath(`\foo\bar\`, true)
if err != nil {
t.Fatalf("unexpected error for backslash path: %v", err)
}
if got.String() != "/foo/bar/" {
t.Errorf("got %q want /foo/bar/", got.String())
}
}
func TestIndexPath_Parent(t *testing.T) {
+4 -3
View File
@@ -6,9 +6,10 @@ import (
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language"` // language code
Title string `json:"title,omitempty"` // title/description
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language"` // language code
Srclang string `json:"srclang,omitempty"` // unique HTML track language tag for Plyr (may differ from Language when duplicated)
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
+33
View File
@@ -1,6 +1,7 @@
package utils
import (
"path"
"path/filepath"
"runtime"
"strings"
@@ -41,6 +42,38 @@ func JoinPathAsUnix(parts ...string) string {
return joinedPath
}
// CleanIndexPathSegment normalizes an index path segment and clamps ".." at the virtual root.
func CleanIndexPathSegment(segment string) string {
return strings.TrimPrefix(path.Clean("/"+segment), "/")
}
// JoinScopedIndexPath joins a user's index scope with a sanitized path segment.
// Unlike filepath.Join, a leading slash on rel does not discard scope (avoids absolute-path scope bypass).
func JoinScopedIndexPath(scope, rel string) string {
rel = CleanIndexPathSegment(rel)
scope = strings.TrimRight(scope, "/")
if rel == "" {
if scope == "" || scope == "/" {
return "/"
}
return scope
}
if scope == "" || scope == "/" {
return "/" + rel
}
return scope + "/" + rel
}
// JoinUnderSourceRoot maps an index path onto a source filesystem root without treating
// indexPath as a host-absolute path (filepath.Join would discard sourceRoot when indexPath starts with "/").
func JoinUnderSourceRoot(sourceRoot, indexPath string) string {
rel := CleanIndexPathSegment(indexPath)
if rel == "" {
return sourceRoot
}
return filepath.Join(sourceRoot, rel)
}
// AddTrailingSlashIfNotExists ensures a directory index path has a trailing slash (root stays "/").
func AddTrailingSlashIfNotExists(indexPath string) string {
if indexPath == "" || indexPath == "/" {
+67
View File
@@ -0,0 +1,67 @@
package utils
import (
"path/filepath"
"testing"
)
func TestJoinScopedIndexPath_absoluteRelDoesNotDropScope(t *testing.T) {
const scope = "/home/alice"
got := JoinScopedIndexPath(scope, "/etc/passwd")
want := "/home/alice/etc/passwd"
if got != want {
t.Fatalf("JoinScopedIndexPath(%q, /etc/passwd) = %q, want %q", scope, got, want)
}
}
func TestJoinScopedIndexPath_traversalResolvedUnderScope(t *testing.T) {
const scope = "/home/alice"
got := JoinScopedIndexPath(scope, "/../../../etc/passwd")
want := "/home/alice/etc/passwd"
if got != want {
t.Fatalf("JoinScopedIndexPath(%q, /../../../etc/passwd) = %q, want %q", scope, got, want)
}
}
func TestJoinScopedIndexPath_relativeTraversalWithoutLeadingSlash(t *testing.T) {
const scope = "/home/alice"
got := JoinScopedIndexPath(scope, "../../../etc/passwd")
want := "/home/alice/etc/passwd"
if got != want {
t.Fatalf("JoinScopedIndexPath(%q, ../../../etc/passwd) = %q, want %q", scope, got, want)
}
}
func TestJoinScopedIndexPath_rootScope(t *testing.T) {
got := JoinScopedIndexPath("/", "projects/acme/file.txt")
if got != "/projects/acme/file.txt" {
t.Fatalf("got %q want /projects/acme/file.txt", got)
}
}
func TestJoinUnderSourceRoot_absoluteIndexPathStaysUnderSource(t *testing.T) {
const sourceRoot = "/srv/mount"
got := JoinUnderSourceRoot(sourceRoot, "/etc/passwd")
want := filepath.Join(sourceRoot, "etc/passwd")
if got != want {
t.Fatalf("JoinUnderSourceRoot = %q, want %q", got, want)
}
}
func TestJoinUnderSourceRoot_relativeIndexPath(t *testing.T) {
const sourceRoot = "/srv/mount"
got := JoinUnderSourceRoot(sourceRoot, "/home/alice/projects/foo")
want := filepath.Join(sourceRoot, "home/alice/projects/foo")
if got != want {
t.Fatalf("JoinUnderSourceRoot = %q, want %q", got, want)
}
}
func TestJoinUnderSourceRoot_relativeTraversalWithoutLeadingSlash(t *testing.T) {
const sourceRoot = "/srv/mount"
got := JoinUnderSourceRoot(sourceRoot, "../../../etc/passwd")
want := filepath.Join(sourceRoot, "etc/passwd")
if got != want {
t.Fatalf("JoinUnderSourceRoot = %q, want %q", got, want)
}
}
+5
View File
@@ -6,3 +6,8 @@ type ViewGrant struct {
Source string
ExpiresAt int64
}
// ShareAccessGrant tracks remaining uses for count-limited share download tokens.
type ShareAccessGrant struct {
RemainingUses int
}
+9 -2
View File
@@ -1,6 +1,7 @@
package utils
import (
"net/http"
"os"
"unicode/utf8"
)
@@ -27,7 +28,13 @@ func IsTextFile(realPath string) (bool, error) {
if len(content) > sampleSize {
sample = content[:sampleSize]
}
// Check 1: Count null bytes - binary files typically have many nulls
// Check 1: Reject PDFs. A PDF with a long ASCII text layer looks like text
// for far more than sampleSize bytes.
if http.DetectContentType(sample) == "application/pdf" {
return false, nil
}
// Check 2: Count null bytes - binary files typically have many nulls
nullCount := 0
for _, b := range sample {
if b == 0x00 {
@@ -42,7 +49,7 @@ func IsTextFile(realPath string) (bool, error) {
}
}
// Check 2: Validate UTF-8 encoding
// Check 3: Validate UTF-8 encoding
// Trim sample to last complete UTF-8 rune to avoid false negatives
trimmedSample := sample
for len(trimmedSample) > 0 {
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff. Show more