Compare commits

..
Author SHA1 Message Date
Graham Steffaniak f3974b28e5 fix onlyoffice csp security regression (#2876) 2026-08-30 11:30:12 -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
187 changed files with 8775 additions and 2541 deletions

No files matched your search

+3
View File
@@ -5,6 +5,9 @@ on:
branches:
- "main"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright
@@ -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'."
+2 -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:
-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 }}
+72
View File
@@ -2,6 +2,78 @@
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.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
+1
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}' \
+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"]
+8 -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
@@ -66,7 +71,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 +99,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")
+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")
}
}
+2 -2
View File
@@ -210,14 +210,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"):
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)
+19 -68
View File
@@ -81,7 +81,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 +128,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
}
@@ -275,35 +234,27 @@ func updatePreviewSettings(user *users.User) bool {
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
func updateSidebarLinks(user *users.User) bool {
updated := false
needsEnsure := usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
validBefore := usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks)
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
user.SidebarLinks = normalized
updated = true
links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
if !changed {
return false
}
if !usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes) {
return updated
user.SidebarLinks = links
if needsEnsure {
if validBefore == 0 && len(user.SidebarLinks) > 0 {
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
} else if validBefore == 0 {
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
} else {
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
}
}
if 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
return true
}
func updateTokens(user *users.User) bool {
+37 -20
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)
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) {
+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
+91 -96
View File
@@ -3,7 +3,7 @@ module github.com/gtsteffaniak/filebrowser/backend
go 1.26.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-systemd/v22 v22.7.0
@@ -19,47 +19,48 @@ require (
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.50
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/crypto v0.55.0
golang.org/x/image v0.45.0
golang.org/x/mod v0.40.0
golang.org/x/net v0.58.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/tools v0.49.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.55.0
modernc.org/sqlite v1.57.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
dev.gaijin.team/go/exhaustruct/v5 v5.0.3 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
github.com/4meepo/tagalign v1.4.3 // indirect
github.com/Abirdcfly/dupword v0.1.7 // 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
@@ -73,43 +74,41 @@ require (
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.2 // 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/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-20260812204455-68fa937c71be // 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.27.4 // 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.7.1 // 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
@@ -117,26 +116,26 @@ require (
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.3 // 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 v0.3.21 // 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.14.4 // 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-openapi/swag/conv v0.29.1 // indirect
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
github.com/go-openapi/swag/loading v0.29.1 // indirect
github.com/go-openapi/swag/pools v0.29.1 // indirect
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // 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 +146,26 @@ 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.0 // 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/gohugoio/hashstructure v1.0.0 // indirect
github.com/gohugoio/hugo v0.165.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/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.1 // 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-20260801150524-d2031e3af752 // 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,15 +177,14 @@ 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
@@ -194,93 +192,90 @@ require (
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.9.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/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.28 // 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/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.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // 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.0 // 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.28.0 // indirect
github.com/sirupsen/logrus v1.10.1 // 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/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/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
github.com/timonwong/loggercheck v0.11.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.0 // 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.0.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 +283,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.0 // 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/exp/typeparams v0.0.0-20260820142414-ca536658362e // 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/text v0.41.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.75.4 // 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.11.0 // indirect
mvdan.cc/unparam v0.0.0-20260820064413-a201bf8e0538 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
tool (
+247 -693
View File
File diff suppressed because it is too large. Load diff
+25 -9
View File
@@ -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 {
+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")
}
}
+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 TestUpdateUserScopesKeepsStaleSidebarLinks(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 != 2 {
t.Fatalf("expected stale include link kept, 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)
}
}
+32 -11
View File
@@ -8,9 +8,10 @@ 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.
// 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 +20,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,11 +32,6 @@ 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)
@@ -43,9 +39,15 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
if strings.TrimSpace(normalized.Name) == "" {
normalized.Name = source.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 +79,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
}
@@ -164,6 +164,88 @@ 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 canonical folder shortcuts unchanged")
}
if len(out) != 4 {
t.Fatalf("len(out) = %d, want 4", len(out))
}
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 != "docker" {
t.Fatalf("first link wins: %#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() {
+28
View File
@@ -0,0 +1,28 @@
package usersidebar
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
// PrepareSidebarLinksForPersist normalizes sidebar links and adds missing scoped source entries.
// Inaccessible sources are not pruned; callers rely on the UI to gray out stale links.
func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
updated := false
if normalized, changed := NormalizeSidebarLinks(links); changed {
links = normalized
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 != "playwright + files" {
t.Fatalf("added scope link = %#v", out[2])
}
}
func TestPrepareSidebarLinksForPersist_keepsLinksWhenScopeRemoved(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=false when only pruning is not performed")
}
if len(out) != 3 {
t.Fatalf("len(out) = %d, want 3 (stale scope link kept)", len(out))
}
}
+19
View File
@@ -89,3 +89,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)
}
}
+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 {
+48
View File
@@ -0,0 +1,48 @@
package utils
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestIsTextFile(t *testing.T) {
t.Parallel()
asciiPageStream := strings.Repeat("BT /Ft0 1 Tf <00430044004500460047> Tj ET\n", 300)
tests := []struct {
name string
content []byte
want bool
}{
{name: "empty", content: nil, want: true},
{name: "plain text", content: []byte("hello\nworld\n"), want: true},
{name: "utf-8 text", content: []byte("café ☕\n"), want: true},
{name: "svg", content: []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>`), want: true},
{name: "binary with null bytes", content: append([]byte("GIF89a"), make([]byte, 64)...), want: false},
{
name: "pdf with an ascii text layer larger than the sample",
content: append([]byte("%PDF-1.3\n"+asciiPageStream), 0xFF, 0xD8, 0xFF, 0xE0),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "sample")
if err := os.WriteFile(path, tt.content, 0o600); err != nil {
t.Fatalf("writing sample: %v", err)
}
got, err := IsTextFile(path)
if err != nil {
t.Fatalf("IsTextFile: %v", err)
}
if got != tt.want {
t.Fatalf("IsTextFile(%s) = %v, want %v", tt.name, got, tt.want)
}
})
}
}
+28 -23
View File
@@ -14,9 +14,9 @@ import (
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
@@ -144,13 +144,18 @@ func getOrCreateAuthenticatedUser(username string, loginMethod users.LoginMethod
return nil, err
}
}
if err := state.SyncUserGroups(username, groups); err != nil {
logger.Warningf("failed to sync ldap user %s groups: %v", username, err)
}
// Verify login method matches
if userValue.LoginMethod != loginMethod {
logger.Debugf("login rejected: user %q attempted %s login but account loginMethod is %q", username, loginMethod, userValue.LoginMethod)
return nil, errors.ErrWrongLoginMethod
}
// Sync IdP groups into access-control GroupMap (write-through). Skip when the
// token/response omitted groups so a missing claim cannot wipe memberships.
if len(groups) > 0 {
if err := state.SyncUserGroups(username, groups); err != nil {
logger.Warningf("failed to sync user %s groups: %v", username, err)
}
}
return &userValue, nil
}
@@ -224,22 +229,12 @@ func sessionCookieDomain(r *http.Request) string {
// @Success 200 {object} map[string]string "{"logoutUrl": "http://..."}"
// @Router /api/auth/logout [post]
func logoutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if err := state.RevokeToken( d.Token); err != nil {
if err := state.RevokeToken(d.Token); err != nil {
logger.Errorf("Failed to revoke token on logout: %v", err)
}
// Clear the authentication cookie by setting it to expire in the past
host := sessionCookieDomain(r)
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: "",
Domain: host,
Path: "/",
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0), // Expire immediately
MaxAge: -1, // Delete cookie
}
http.SetCookie(w, cookie)
http.SetCookie(w, sessionCookie(r, "", time.Unix(0, 0), -1, http.SameSiteStrictMode))
logoutUrl := fmt.Sprintf("%vlogin", settings.Config.Http.BaseURL) // Default fallback
if d.User != nil && d.User.LoginMethod == users.LoginMethodProxy {
@@ -423,17 +418,27 @@ func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
return 200, nil
}
// SetSessionCookie sets the authentication token as an HTTP cookie.
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
const sessionCookieName = "filebrowser_quantum_jwt"
// sessionCookie builds the JWT cookie. HttpOnly prevents srcdoc XSS from reading
// document.cookie; Secure is set when the request is HTTPS (including via proxy).
func sessionCookie(r *http.Request, token string, expiresTime time.Time, maxAge int, sameSite http.SameSite) *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
Value: token,
Domain: sessionCookieDomain(r),
Path: "/",
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
SameSite: sameSite,
HttpOnly: true,
Secure: requestScheme(r) == "https",
Expires: expiresTime,
MaxAge: maxAge,
}
http.SetCookie(w, cookie)
}
// SetSessionCookie sets the authentication token as an HTTP cookie.
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
http.SetCookie(w, sessionCookie(r, token, expiresTime, 0, http.SameSiteStrictMode))
}
// applyNamedApiTokenGlobalCaps intersects owner globals with JWT global caps for named custom API tokens.
+16
View File
@@ -0,0 +1,16 @@
package web
import (
libError "errors"
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
)
// loginMethodHTTPStatus maps a login error to an HTTP status and client-safe message.
func loginMethodHTTPStatus(err error) (int, error) {
if libError.Is(err, errors.ErrWrongLoginMethod) {
return http.StatusUnauthorized, errors.ErrInvalidLoginMethod
}
return 0, err
}
@@ -0,0 +1,121 @@
package web
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
fberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestGetOrCreateAuthenticatedUserRejectsLoginMethodMismatch(t *testing.T) {
setupTestEnv(t)
passwordUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodPassword,
},
}
if err := state.CreateUser(passwordUser, "secret"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
_, err := getOrCreateAuthenticatedUser("graham", users.LoginMethodOidc, false, nil)
if !errors.Is(err, fberrors.ErrWrongLoginMethod) {
t.Fatalf("getOrCreateAuthenticatedUser() err = %v, want ErrWrongLoginMethod", err)
}
loaded, err := state.GetUserByUsername("graham")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
if loaded.LoginMethod != users.LoginMethodPassword {
t.Fatalf("loginMethod changed to %q, want password", loaded.LoginMethod)
}
}
func TestGetOrCreateAuthenticatedUserRejectsPasswordUserForOIDCAccount(t *testing.T) {
setupTestEnv(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, ""); err != nil {
t.Fatalf("CreateUser: %v", err)
}
_, err := getOrCreateAuthenticatedUser("graham", users.LoginMethodOidc, false, nil)
if err != nil {
t.Fatalf("getOrCreateAuthenticatedUser() for matching oidc user: %v", err)
}
}
func TestAuthenticatePasswordRejectsWrongLoginMethod(t *testing.T) {
setupTestEnv(t)
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, "secret"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=graham", nil)
req.Header.Set("X-Password", url.QueryEscape("secret"))
_, err := auth.AuthenticatePassword(req, true)
if !errors.Is(err, fberrors.ErrWrongLoginMethod) {
t.Fatalf("AuthenticatePassword() err = %v, want ErrWrongLoginMethod", err)
}
}
func TestLoginHelperReturnsInvalidLoginMethodForPasswordMismatch(t *testing.T) {
setupTestEnv(t)
app.MustWireServices(state.Default())
oidcUser := &users.User{
FrontendUser: users.FrontendUser{
Username: "graham",
LoginMethod: users.LoginMethodOidc,
},
}
if err := state.CreateUser(oidcUser, "secret"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
origPassword := settings.Config.Auth.Methods.PasswordAuth.Enabled
settings.Config.Auth.Methods.PasswordAuth.Enabled = true
t.Cleanup(func() {
settings.Config.Auth.Methods.PasswordAuth.Enabled = origPassword
})
handler := LoginHelper(true, func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
return http.StatusOK, nil
})
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=graham", nil)
req.Header.Set("X-Password", url.QueryEscape("secret"))
rec := httptest.NewRecorder()
status, err := handler(rec, req, &requestContext{})
if status != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", status)
}
if !errors.Is(err, fberrors.ErrInvalidLoginMethod) {
t.Fatalf("err = %v, want ErrInvalidLoginMethod", err)
}
}
+2 -2
View File
@@ -189,7 +189,7 @@ func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *Context) (
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
}
filePath := utils.JoinPathAsUnix(d.Share.Path, cleanFile)
filePath := utils.JoinScopedIndexPath(d.Share.Path, cleanFile)
fileList = append(fileList, filePath)
}
@@ -242,7 +242,7 @@ func RawFilesHandler(w http.ResponseWriter, r *http.Request, d *Context, source
return http.StatusForbidden, err
}
for i, filePath := range fileList {
fileList[i] = utils.JoinPathAsUnix(userscope, filePath)
fileList[i] = utils.JoinScopedIndexPath(userscope, filePath)
}
}
firstFilePath = fileList[0]
+5
View File
@@ -18,6 +18,7 @@ import (
libErrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-cache/cache"
@@ -127,6 +128,8 @@ func ErrToStatus(err error) int {
return http.StatusForbidden
case errors.Is(err, libErrors.ErrAccessDenied):
return http.StatusForbidden
case errors.Is(err, libErrors.ErrPathEscapesScope):
return http.StatusForbidden
case os.IsNotExist(err), err == libErrors.ErrNotExist:
return http.StatusNotFound
case os.IsExist(err), err == libErrors.ErrExist:
@@ -137,6 +140,8 @@ func ErrToStatus(err error) int {
return http.StatusBadRequest
case errors.Is(err, libErrors.ErrIsDirectory):
return http.StatusMethodNotAllowed
case errors.Is(err, preview.ErrUnsupportedFormat):
return http.StatusUnsupportedMediaType
default:
return http.StatusInternalServerError
}
+4 -3
View File
@@ -118,12 +118,12 @@ func configureHTTPRouter(router, api, publicRoutes, publicApi *http.ServeMux) {
api.HandleFunc("PUT /settings/analytics", withTimeout(time5s, withAdminHelper(settingsAnalyticsUpdateHandler)))
api.HandleFunc("PATCH /settings/analytics", withTimeout(time5s, withAdminHelper(settingsAnalyticsUpdateHandler)))
api.HandleFunc("GET /settings/analytics/preview", withTimeout(time30s, withAdminHelper(settingsAnalyticsPreviewHandler)))
api.HandleFunc("GET /settings/user-defaults", withTimeout(time5s, withUserHelper(settingsUserDefaultsGetHandler)))
api.HandleFunc("PATCH /settings/user-defaults", withTimeout(time5s, withAdminHelper(settingsUserDefaultsPatchHandler)))
publicApi.HandleFunc("GET /settings/user-defaults", withTimeout(time5s, withUserHelper(settingsUserDefaultsGetHandler)))
api.HandleFunc("GET /settings/source", withTimeout(time5s, withUserHelper(settingsSourceGetHandler)))
api.HandleFunc("PATCH /settings/source", withTimeout(time5s, withAdminHelper(settingsSourcePatchHandler)))
api.HandleFunc("GET /settings/sources", withUser(getSourceInfoHandler))
api.HandleFunc("GET /settings/user-defaults", withTimeout(time5s, withUserHelper(settingsUserDefaultsGetHandler)))
api.HandleFunc("PATCH /settings/user-defaults", withTimeout(time5s, withAdminHelper(settingsUserDefaultsPatchHandler)))
publicApi.HandleFunc("GET /settings/user-defaults", withTimeout(time5s, withUserHelper(settingsUserDefaultsGetHandler)))
// ========================================
// Tools Routes - /api/tools/
@@ -143,6 +143,7 @@ func configureHTTPRouter(router, api, publicRoutes, publicApi *http.ServeMux) {
api.HandleFunc("GET /media/metadata", withTimeout(time60s, withUserHelper(metadataHandler)))
api.HandleFunc("GET /media/lyrics", withTimeout(time60s, withUserHelper(lyricsHandler)))
api.HandleFunc("GET /media/stream", withTimeout(time60s, withUserHelper(streamHandler)))
publicApi.HandleFunc("GET /media/subtitles", withTimeout(time60s, withHashFileHelper(publicSubtitlesHandler)))
publicApi.HandleFunc("GET /media/metadata", withTimeout(time60s, withHashFileHelper(publicMetadataHandler)))
publicApi.HandleFunc("GET /media/lyrics", withTimeout(time60s, withHashFileHelper(publicLyricsHandler)))
publicApi.HandleFunc("GET /media/stream", withTimeout(time60s, withHashFileHelper(publicStreamHandler)))
+24
View File
@@ -0,0 +1,24 @@
package web
import (
"errors"
"net/http"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
)
func TestErrToStatusUnsupportedPreviewFormat(t *testing.T) {
status := ErrToStatus(preview.ErrUnsupportedFormat)
if status != http.StatusUnsupportedMediaType {
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
}
}
func TestErrToStatusWrapsUnsupportedPreviewFormat(t *testing.T) {
err := errors.Join(errors.New("preview failed"), preview.ErrUnsupportedFormat)
status := ErrToStatus(err)
if status != http.StatusUnsupportedMediaType {
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
}
}
+1 -5
View File
@@ -66,11 +66,7 @@ func authenticateLDAP(username, password string) ([]string, map[string]string, e
logger.Debugf("ldap: no service account bind (userPassword empty)")
}
// Build list of attributes to fetch
groupAttr := c.GroupsClaim
if groupAttr == "" {
groupAttr = "memberOf"
}
groupAttr := settings.ResolveLdapGroupsClaim(c.GroupsClaim)
attributes := []string{"dn", groupAttr}
if c.UserIdentifier != "" {
attributes = append(attributes, c.UserIdentifier)
+63 -17
View File
@@ -13,7 +13,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// subtitlesHandler handles subtitle requests for both external files and embedded streams
@@ -33,17 +32,18 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/media/subtitles [get]
func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
name := r.URL.Query().Get("name")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
name := r.URL.Query().Get("name")
embedded := r.URL.Query().Get("embedded") == "true"
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
if name == "" {
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
}
if path == "" {
return http.StatusBadRequest, fmt.Errorf("path parameter is required")
}
filePerms, err := effectiveFilePerms(d, source)
if err != nil {
return http.StatusForbidden, err
@@ -52,18 +52,47 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return http.StatusForbidden, fmt.Errorf("user is not allowed to view files in this source")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.User.ShowHidden,
HideFileExt: d.User.HideFileExt,
SkipExtendedAttrs: false,
}, d.User)
fileUser := d.User
var fileOpts utils.FileOptions
if d.Share.Hash != "" {
source = d.Share.GetSourceName()
if source == "" {
return http.StatusNotFound, fmt.Errorf("source not found")
}
path = d.IndexPath
fileUser = d.ShareUser
fileOpts = utils.FileOptions{
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && d.Share.ExtractEmbeddedSubtitles,
ShowHidden: d.Share.ShowHidden,
HideFileExt: d.User.HideFileExt,
FollowSymlinks: false,
SkipExtendedAttrs: false,
}
} else {
if source == "" {
return http.StatusBadRequest, fmt.Errorf("source is required")
}
fileOpts = utils.FileOptions{
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.User.ShowHidden,
HideFileExt: d.User.HideFileExt,
SkipExtendedAttrs: false,
}
}
fileInfo, err := files.FileInfoFaster(fileOpts, fileUser)
if err != nil {
return ErrToStatus(err), err
}
@@ -289,3 +318,20 @@ func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
}
return RenderJSON(w, r, map[string]any{"lyrics": lyrics})
}
// publicSubtitlesHandler is the share-link variant of subtitlesHandler.
// @Summary Get subtitle content (public share)
// @Tags Media
// @Produce text/plain
// @Param hash query string true "Share hash"
// @Param path query string false "Path within the share"
// @Param name query string true "Subtitle track name"
// @Param embedded query bool false "Whether this is an embedded stream"
// @Success 200 {string} string "Raw subtitle content"
// @Router /public/api/media/subtitles [get]
func publicSubtitlesHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
if d.Share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
}
return subtitlesHandler(w, r, d)
}
+17 -7
View File
@@ -125,6 +125,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
(r.Method == "POST" && strings.Contains(r.URL.Path, "/resources/view-token")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/subtitles")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/download")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/view")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/stream")) {
@@ -201,7 +202,10 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
data.Token = tokenString
var tk users.AuthToken
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
// Validate signature without enforcing exp/iat so expired session JWTs can still yield
// user context on public-share routes; revocation is checked explicitly below.
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
token, err := parser.ParseWithClaims(tokenString, &tk, keyFunc)
if err != nil {
return nil
}
@@ -210,8 +214,9 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
return nil
}
// Token is valid (but might be expired or revoked)
// Try to get the user regardless of expiration status
if state.IsTokenRevoked(tokenString) {
return nil
}
var user *users.User
userValue, err := state.UserFromAPIToken(tk, tokenString)
if err == nil {
@@ -382,6 +387,9 @@ func LoginHelper(disableOtp bool, fn handleFunc) handleFunc {
if err == errors.ErrNoTotpProvided {
return 403, err
}
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return 401, errors.ErrUnauthorized
}
d.User = user
@@ -455,10 +463,6 @@ func withUserHelper(fn handleFunc) handleFunc {
if tk.RegisteredClaims.ExpiresAt == nil {
return http.StatusUnauthorized, fmt.Errorf("token is invalid or revoked")
}
// Check if token is about to expire for renewal header
if tk.RegisteredClaims.ExpiresAt.Unix() < time.Now().Add(time.Minute*30).Unix() {
w.Header().Add("X-Renew-Token", "true")
}
userValue, err := state.UserFromAPIToken(tk, data.Token)
if err == nil {
data.User = &userValue
@@ -503,6 +507,9 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
// Setup user based on JWT claims
user, err := SetupJwtUser(r, data, username, claims)
if err != nil {
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return http.StatusForbidden, err
}
data.User = user
@@ -534,6 +541,9 @@ func getProxyUser(w http.ResponseWriter, r *http.Request, data *requestContext,
// proxy user logic
user, err := SetupProxyUser(r, data, proxyUser)
if err != nil {
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return http.StatusForbidden, err
}
data.User = user
+169
View File
@@ -149,6 +149,175 @@ func TestWithAdminHelper(t *testing.T) {
}
}
func TestPublicShare_RejectsRevokedJWT(t *testing.T) {
setupTestEnv(t)
victim := &users.User{
FrontendUser: users.FrontendUser{
Username: "victim",
Permissions: users.Permissions{Api: true},
},
BackendScopes: []users.BackendScope{
{Path: "/srv", Scope: "/"},
},
}
if err := state.CreateUser(victim, ""); err != nil {
t.Fatal("failed to create victim user:", err)
}
victim.Permissions = users.Permissions{Api: true}
if err := state.UpdateUser(victim, "", "permissions"); err != nil {
t.Fatal("failed to set victim permissions:", err)
}
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
tokenString, _, err := auth.MakeSignedTokenAPI(victim, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, victim.Permissions, false)
if err != nil {
t.Fatalf("failed to issue token: %v", err)
}
shareLink := &share.Share{
ShareSettings: share.ShareSettings{
ShareLimits: share.ShareLimits{
SourceName: "srv",
AllowedUsernames: []string{"victim"},
},
},
ShareColumns: share.ShareColumns{
Hash: "revoked_jwt_hash",
Path: "/",
},
SourcePath: "/srv",
UserID: victim.ID,
}
if err := state.CreateShare(shareLink); err != nil {
t.Fatal("failed to create share:", err)
}
handler := withHashFile(publicGetResourceHandler)
makeRequest := func(jwt string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/public/api/resources?hash=revoked_jwt_hash&path=/", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: jwt,
})
handler(recorder, req)
return recorder
}
// Valid token: allow-listed user can access the share.
rec := makeRequest(tokenString)
if rec.Code != http.StatusOK {
t.Fatalf("valid token: expected status %d, got %d", http.StatusOK, rec.Code)
}
if err := state.RevokeToken(tokenString); err != nil {
t.Fatalf("failed to revoke token: %v", err)
}
// Revoked token must not resurrect the victim on public-share routes.
rec = makeRequest(tokenString)
if rec.Code == http.StatusOK {
t.Fatalf("revoked token: expected non-200 status, got %d (JWT resurrection bypass)", rec.Code)
}
}
func TestExtractUserFromExpiredToken_RejectsRevokedJWT(t *testing.T) {
_, tokenString := issueExtractUserTestToken(t, time.Hour*2)
if err := state.RevokeToken(tokenString); err != nil {
t.Fatalf("failed to revoke token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
})
data := &requestContext{}
if got := extractUserFromExpiredToken(req, data); got != nil {
t.Fatalf("extractUserFromExpiredToken() = user %q, want nil for revoked token", got.Username)
}
}
func TestExtractUserFromExpiredToken_AcceptsExpiredNonRevoked(t *testing.T) {
user, tokenString := issueExtractUserTestToken(t, -time.Hour)
req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
})
data := &requestContext{}
got := extractUserFromExpiredToken(req, data)
if got == nil {
t.Fatal("extractUserFromExpiredToken() = nil, want user for expired non-revoked token")
}
if got.Username != user.Username {
t.Fatalf("extractUserFromExpiredToken() username = %q, want %q", got.Username, user.Username)
}
}
func TestExtractUserFromExpiredToken_RejectsRevokedExpired(t *testing.T) {
_, tokenString := issueExtractUserTestToken(t, -time.Hour)
if err := state.RevokeToken(tokenString); err != nil {
t.Fatalf("failed to revoke token: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/public/api/resources", http.NoBody)
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
})
data := &requestContext{}
if got := extractUserFromExpiredToken(req, data); got != nil {
t.Fatalf("extractUserFromExpiredToken() = user %q, want nil for revoked expired token", got.Username)
}
}
func issueExtractUserTestToken(t *testing.T, duration time.Duration) (*users.User, string) {
t.Helper()
setupTestEnv(t)
stored := &users.User{
FrontendUser: users.FrontendUser{
Username: "victim",
Permissions: users.Permissions{Api: true},
},
}
if err := state.CreateUser(stored, ""); err != nil {
t.Fatal("failed to create user:", err)
}
user, err := state.GetUserByUsername("victim")
if err != nil {
t.Fatal("failed to load user:", err)
}
user.Permissions = users.Permissions{Api: true}
if err = state.UpdateUser(&user, "", "permissions"); err != nil {
t.Fatal("failed to set user permissions:", err)
}
user, err = state.GetUserByUsername("victim")
if err != nil {
t.Fatal("failed to reload user:", err)
}
originalAuthKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "key"
t.Cleanup(func() { settings.Config.Auth.Key = originalAuthKey })
tokenString, _, err := auth.MakeSignedTokenAPI(&user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), duration, user.Permissions, false)
if err != nil {
t.Fatalf("failed to issue token: %v", err)
}
return &user, tokenString
}
func TestPublicShareHandlerAuthentication(t *testing.T) {
setupTestEnv(t)
+32 -16
View File
@@ -239,9 +239,10 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
rawIDToken, ok := token.Extra("id_token").(string)
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
var userdata userInfo // Declare userdata here to be populated by either source
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
loginUsername := "" // Variable to hold the login username
var userdata userInfo // Declare userdata here to be populated by either source
var idTokenClaims map[string]any // Verified ID-token claims preserved across UserInfo fallback
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
loginUsername := "" // Variable to hold the login username
// Create custom unmarshaller for userInfo
userInfoUnmarshaller := &userInfoUnmarshaller{
@@ -267,11 +268,19 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
} else {
// Successfully verified and decoded ID token claims
logger.Debugf("ID Token verified and claims decoded: %+v", userdata)
if len(userdata.Claims) > 0 {
idTokenClaims = maps.Clone(userdata.Claims)
}
// Decide if we rely on ID token claims or still need UserInfo
// Even if parsing succeeded, if essential claims are missing, use UserInfo
if _, ok := userdata.Claims[oidcCfg.UserIdentifier]; ok {
claimsFromIDToken = true
needsGroups := oidcCfg.AdminGroup != "" || len(oidcCfg.UserGroups) > 0
if needsGroups && len(userdata.Groups) == 0 {
logger.Debugf("OIDC: ID token missing groups claim (adminGroup/userGroups configured); fetching UserInfo")
claimsFromIDToken = false
}
}
}
}
@@ -296,6 +305,9 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
logger.Errorf("failed to decode user info from endpoint: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to decode user info from endpoint: %v", err)
}
if len(idTokenClaims) > 0 {
mergeMissingOidcClaims(&userdata, idTokenClaims)
}
}
// --- Determine login username dynamically ---
@@ -314,6 +326,18 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
return loginWithOidcUser(w, r, loginUsername, userdata.Groups)
}
// mergeMissingOidcClaims copies claims from from into userdata when absent after UserInfo fallback.
func mergeMissingOidcClaims(userdata *userInfo, from map[string]any) {
if userdata.Claims == nil {
userdata.Claims = make(map[string]any)
}
for k, v := range from {
if _, exists := userdata.Claims[k]; !exists {
userdata.Claims[k] = v
}
}
}
// loginWithOidcUser extracts the username from the user claims (userInfo)
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
@@ -352,6 +376,9 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
user, err := getOrCreateAuthenticatedUser(username, users.LoginMethodOidc, isAdmin, groups)
if err != nil {
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
return status, mapped
}
return http.StatusUnauthorized, err
}
@@ -370,19 +397,8 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
// This allows backend to identify expired sessions and provide better user feedback
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
// Set the authentication token as an HTTP cookie
host := requestHost(r)
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: tokenString,
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
Path: "/",
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
Expires: expiresTime,
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
// Secure: true, // Enable this in production with HTTPS
}
http.SetCookie(w, cookie)
// Set the authentication token as an HTTP cookie (Lax for OIDC redirect return).
http.SetCookie(w, sessionCookie(r, tokenString, expiresTime, 0, http.SameSiteLaxMode))
// Set the authenticated user in the request context or response writer
// This allows subsequent handlers to access the current user.
+36
View File
@@ -206,6 +206,42 @@ func TestUserInfoUnmarshaller_InvalidJSON(t *testing.T) {
}
}
func TestMergeMissingOidcClaims_PreservesIDTokenIdentifier(t *testing.T) {
userdata := userInfo{
Claims: map[string]any{"groups": []any{"admins"}},
Groups: []string{"admins"},
}
idTokenClaims := map[string]any{
"preferred_username": "alice",
"sub": "user-123",
}
mergeMissingOidcClaims(&userdata, idTokenClaims)
if userdata.Claims["preferred_username"] != "alice" {
t.Fatalf("preferred_username = %v, want alice", userdata.Claims["preferred_username"])
}
if userdata.Claims["sub"] != "user-123" {
t.Fatalf("sub = %v, want user-123", userdata.Claims["sub"])
}
if _, ok := userdata.Claims["groups"]; !ok {
t.Fatal("UserInfo groups claim should be preserved")
}
}
func TestMergeMissingOidcClaims_UserInfoWinsOnConflict(t *testing.T) {
userdata := userInfo{
Claims: map[string]any{"email": "from-userinfo@example.com"},
}
idTokenClaims := map[string]any{"email": "from-idtoken@example.com"}
mergeMissingOidcClaims(&userdata, idTokenClaims)
if userdata.Claims["email"] != "from-userinfo@example.com" {
t.Fatalf("email = %v, want UserInfo value preserved", userdata.Claims["email"])
}
}
func TestOidcRedirectURLTrustProxyHeaders(t *testing.T) {
settings.Config.Http.BaseURL = "/"
settings.Config.Http.TrustProxyHeaders = true
+23 -6
View File
@@ -213,11 +213,13 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
return http.StatusForbidden, err
}
shareToken := shareTokenForOnlyOffice(d)
// Build view URL that OnlyOffice server will use to fetch the file
documentURL := buildOnlyOfficeViewURL(r, source, providedPath, d.FileInfo.Hash, viewToken, d.Token)
documentURL := buildOnlyOfficeViewURL(r, source, providedPath, d.FileInfo.Hash, viewToken, d.Token, shareToken)
// Build callback URL for OnlyOffice to notify us of changes
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.FileInfo.Hash, d.Token)
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.FileInfo.Hash, d.Token, shareToken)
// Build OnlyOffice client configuration
clientConfig := map[string]interface{}{
@@ -296,14 +298,26 @@ func joinOnlyOfficeAPIURL(baseURL, apiPath string) string {
return base + "/" + path
}
// shareTokenForOnlyOffice returns the share download token for OnlyOffice server URLs.
// Only password-protected shares have a token; callers must already have passed share auth.
func shareTokenForOnlyOffice(d *Context) string {
if d.Share.Hash == "" || d.Share.PasswordHash == "" || d.Share.Token == "" {
return ""
}
return d.Share.Token
}
// buildOnlyOfficeViewURL constructs the view URL that OnlyOffice server uses to fetch the document.
func buildOnlyOfficeViewURL(r *http.Request, source, path, hash, viewToken, authToken string) string {
func buildOnlyOfficeViewURL(r *http.Request, source, path, hash, viewToken, authToken, shareToken string) string {
baseURL := onlyOfficeFileBrowserBaseURL(r)
params := url.Values{}
params.Set("file", path)
params.Set("viewToken", viewToken)
if hash != "" {
params.Set("hash", hash)
if shareToken != "" {
params.Set("token", shareToken)
}
return joinOnlyOfficeAPIURL(baseURL, "public/api/resources/view") + "?" + params.Encode()
}
params.Set("source", source)
@@ -312,7 +326,7 @@ func buildOnlyOfficeViewURL(r *http.Request, source, path, hash, viewToken, auth
}
// buildOnlyOfficeCallbackURL constructs the callback URL that OnlyOffice server will use to notify us of changes
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token string) string {
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, authToken, shareToken string) string {
baseURL := onlyOfficeFileBrowserBaseURL(r)
params := url.Values{}
@@ -320,14 +334,17 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
// Share callback URL - use public API and don't expose source, use path relative to share
params.Set("hash", hash)
params.Set("path", path)
params.Set("auth", token)
params.Set("auth", authToken)
if shareToken != "" {
params.Set("token", shareToken)
}
return joinOnlyOfficeAPIURL(baseURL, "public/api/office/callback") + "?" + params.Encode()
}
// Regular callback URL - include source for non-share requests
params.Set("source", source)
params.Set("path", path)
params.Set("auth", token)
params.Set("auth", authToken)
return joinOnlyOfficeAPIURL(baseURL, "api/office/callback") + "?" + params.Encode()
}
+80 -2
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
@@ -60,19 +61,96 @@ func TestBuildOnlyOfficeViewURL(t *testing.T) {
settings.Config.Http.BaseURL = "/testing/"
req := httptest.NewRequest(http.MethodGet, "/", nil)
got := buildOnlyOfficeViewURL(req, "Downloads", "/doc.docx", "", "view-token", "session.jwt.token")
got := buildOnlyOfficeViewURL(req, "Downloads", "/doc.docx", "", "view-token", "session.jwt.token", "")
want := "http://filebrowser:8080/testing/api/resources/view?auth=session.jwt.token&file=%2Fdoc.docx&source=Downloads&viewToken=view-token"
if got != want {
t.Fatalf("buildOnlyOfficeViewURL() = %q, want %q", got, want)
}
shareGot := buildOnlyOfficeViewURL(req, "Downloads", "/doc.docx", "share-hash", "view-token", "session.jwt.token")
shareGot := buildOnlyOfficeViewURL(req, "Downloads", "/doc.docx", "share-hash", "view-token", "session.jwt.token", "")
shareWant := "http://filebrowser:8080/testing/public/api/resources/view?file=%2Fdoc.docx&hash=share-hash&viewToken=view-token"
if shareGot != shareWant {
t.Fatalf("buildOnlyOfficeViewURL(share) = %q, want %q", shareGot, shareWant)
}
}
func TestBuildOnlyOfficeShareURLs(t *testing.T) {
origHTTP := settings.Config.Http
t.Cleanup(func() { settings.Config.Http = origHTTP })
settings.Config.Http.BaseURL = "/files/"
settings.Config.Http.InternalUrl = ""
req := httptest.NewRequest(http.MethodGet, "http://example.com/files/public/api/office/config?hash=abc", nil)
t.Run("share view URL includes share token", func(t *testing.T) {
got := buildOnlyOfficeViewURL(req, "source", "/doc.docx", "shareHash", "view-token", "jwt-token", "share-download-token")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse view URL: %v", err)
}
q := parsed.Query()
if q.Get("hash") != "shareHash" {
t.Errorf("hash = %q, want shareHash", q.Get("hash"))
}
if q.Get("viewToken") != "view-token" {
t.Errorf("viewToken = %q, want view-token", q.Get("viewToken"))
}
if q.Get("token") != "share-download-token" {
t.Errorf("token = %q, want share-download-token", q.Get("token"))
}
if q.Get("file") != "/doc.docx" {
t.Errorf("file = %q, want /doc.docx", q.Get("file"))
}
})
t.Run("share view URL omits token when empty", func(t *testing.T) {
got := buildOnlyOfficeViewURL(req, "source", "/doc.docx", "shareHash", "view-token", "jwt-token", "")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse view URL: %v", err)
}
if parsed.Query().Get("token") != "" {
t.Errorf("expected no token param, got %q", parsed.Query().Get("token"))
}
})
t.Run("non-share view URL unchanged", func(t *testing.T) {
got := buildOnlyOfficeViewURL(req, "my-source", "/doc.docx", "", "view-token", "jwt-token", "share-download-token")
if strings.Contains(got, "token=") {
t.Errorf("non-share URL should not include share token: %q", got)
}
if !strings.Contains(got, "source=my-source") {
t.Errorf("expected source in URL, got %q", got)
}
})
t.Run("share callback URL includes share token", func(t *testing.T) {
got := buildOnlyOfficeCallbackURL(req, "source", "/doc.docx", "shareHash", "jwt-token", "share-download-token")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse callback URL: %v", err)
}
q := parsed.Query()
if q.Get("token") != "share-download-token" {
t.Errorf("token = %q, want share-download-token", q.Get("token"))
}
if q.Get("auth") != "jwt-token" {
t.Errorf("auth = %q, want jwt-token", q.Get("auth"))
}
})
t.Run("share callback URL omits token when empty", func(t *testing.T) {
got := buildOnlyOfficeCallbackURL(req, "source", "/doc.docx", "shareHash", "jwt-token", "")
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse callback URL: %v", err)
}
if parsed.Query().Get("token") != "" {
t.Errorf("expected no token param, got %q", parsed.Query().Get("token"))
}
})
}
func TestJoinOnlyOfficeAPIURL(t *testing.T) {
tests := []struct {
base string
+211 -76
View File
@@ -2,8 +2,6 @@ package web
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -25,10 +23,9 @@ import (
"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-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
var pauseCache = cache.NewCache[string](1 * time.Minute)
@@ -393,7 +390,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: err.Error(),
})
continue
@@ -404,7 +401,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if sourceName == "" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: "source name is empty",
})
continue
@@ -414,7 +411,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if idx == nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: "source not found",
})
continue
@@ -423,7 +420,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if idx.Config.ReadOnly {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: "source is read-only",
})
continue
@@ -434,13 +431,13 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: "user does not have access",
})
continue
}
withoutUserScope := strings.TrimPrefix(d.Share.Path, userScope)
indexPath := utils.JoinPathAsUnix(withoutUserScope, sanitizedPath)
indexPath := utils.JoinScopedIndexPath(withoutUserScope, sanitizedPath)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
@@ -458,7 +455,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: "error deleting file/directory, admin must check the logs",
})
continue
@@ -470,7 +467,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if item.Source == "" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: "source was empty, source is required",
})
continue
@@ -481,7 +478,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: fmt.Sprintf("user does not have access: %v", err),
})
continue
@@ -490,7 +487,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if permErr != nil || !filePerms.Delete {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: "user is not allowed to delete",
})
continue
@@ -500,7 +497,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if idx == nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: "source not found",
})
continue
@@ -509,7 +506,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if idx.Config.ReadOnly {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: "source is read-only",
})
continue
@@ -525,7 +522,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: err.Error(),
})
continue
@@ -534,16 +531,18 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: sanitizedPath,
Path: rawPath,
Message: err.Error(),
})
continue
}
preview.DelThumbs(r.Context(), *fileInfo)
}
// Success (log canonical path/source used for deletion)
loggedItem := item
loggedItem.Path = sanitizedPath
// Success — echo the client path in the response
loggedItem := BulkDeleteItem{
Source: item.Source,
Path: rawPath,
}
if d.Share.Hash != "" {
loggedItem.Source = d.Share.GetSourceName()
}
@@ -629,7 +628,7 @@ func resourcePauseHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userscope, cleanPath)
fullIndexPath := utils.JoinScopedIndexPath(userscope, cleanPath)
if !state.AccessPermitted(idx.Path, utils.IndexPathFromNormalized(fullIndexPath, true), d.User.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", fullIndexPath)
}
@@ -718,7 +717,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
}
userscope = strings.TrimRight(userscope, "/")
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
fullIndexPath := utils.JoinScopedIndexPath(userscope, path)
// get scoped path
realPath, _, _ := idx.GetRealPath(fullIndexPath)
@@ -782,12 +781,35 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
}
var totalSize int64
totalSizeStr := r.Header.Get("X-File-Total-Size")
totalSize, err = strconv.ParseInt(totalSizeStr, 10, 64)
var hasTotalSize bool
totalSize, hasTotalSize, err = parseUploadTotalSize(r)
if err != nil {
logger.Debugf("invalid total size: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid total size: %v", err)
logger.Debugf("%v", err)
return http.StatusBadRequest, err
}
if !hasTotalSize {
return http.StatusBadRequest, fmt.Errorf("invalid total size: missing X-File-Total-Size")
}
if boundsErr := validateChunkBounds(offset, r.ContentLength, totalSize); boundsErr != nil {
logger.Debugf("%v", boundsErr)
return http.StatusBadRequest, boundsErr
}
sessionID, sessionErr := parseUploadSession(r)
if sessionErr != nil {
logger.Debugf("%v", sessionErr)
return http.StatusBadRequest, sessionErr
}
if acquireErr := activeUploadSessions.acquire(realPath, sessionID); acquireErr != nil {
if isUploadSessionConflict(acquireErr) {
logger.Debugf("%v", acquireErr)
drainRequestBody(r)
return http.StatusConflict, acquireErr
}
logger.Debugf("%v", acquireErr)
return http.StatusInternalServerError, acquireErr
}
// On the first chunk, check for conflicts or handle override
if offset == 0 {
// Check for file/folder conflicts for chunked uploads
@@ -798,6 +820,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
// If type mismatch (existing dir vs requesting file) and not overriding
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
logger.Debugf("Type conflict detected in chunked: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
drainRequestBody(r)
return http.StatusConflict, nil
}
}
@@ -807,6 +830,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
drainRequestBody(r)
return http.StatusConflict, nil
}
// If overriding, delete existing thumbnails
@@ -814,11 +838,8 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
}
}
// Use a temporary file in the cache directory for chunks.
// Create a unique name for the temporary file to avoid collisions.
hasher := md5.New()
hasher.Write([]byte(realPath))
tempFilePath := fmt.Sprintf("%s.%s.uploading.tmp", realPath, hex.EncodeToString(hasher.Sum(nil)))
// Use a temporary file for chunks.
tempFilePath := uploadTempPath(realPath, sessionID)
// Create or open the temporary file
var outFile *os.File
outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, fileutils.PermFile)
@@ -860,15 +881,51 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
}
}
// Keep the partial temp file so the client can resume from offset.
if gracefulPause {
logger.Debugf("chunk upload ended after graceful pause; keeping partial file (source=%s path=%s)", source, path)
return 499, nil
}
_ = os.Remove(tempFilePath)
logger.Debugf("chunk upload failed; keeping partial file for resume (source=%s path=%s offset=%d)", source, path, offset)
return http.StatusInternalServerError, fmt.Errorf("could not write chunk to temp file: %v", err)
}
// check if the file is complete
if (offset + chunkSize) >= totalSize {
if err = validateReceivedBytes(chunkSize, 0, false, r.ContentLength); err != nil {
logger.Debugf("incomplete chunk: %v", err)
if truncErr := outFile.Truncate(offset); truncErr != nil {
logger.Debugf("could not truncate temp file after incomplete chunk (offset=%d): %v", offset, truncErr)
}
_ = outFile.Sync()
return http.StatusBadRequest, err
}
if err = validateAssembledSize(offset, chunkSize, totalSize); err != nil {
logger.Debugf("%v", err)
if truncErr := outFile.Truncate(offset); truncErr != nil {
logger.Debugf("could not truncate temp file after oversized chunk (offset=%d): %v", offset, truncErr)
}
_ = outFile.Sync()
return http.StatusBadRequest, err
}
assembled := offset + chunkSize
// Finalize only when the assembled size matches the declared total exactly.
if assembled == totalSize {
if syncErr := outFile.Sync(); syncErr != nil {
logger.Debugf("could not sync temp file before finalize: %v", syncErr)
return http.StatusInternalServerError, fmt.Errorf("could not sync temp file: %v", syncErr)
}
var info os.FileInfo
info, err = outFile.Stat()
if err != nil {
logger.Debugf("could not stat temp file before finalize: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not stat temp file: %v", err)
}
if info.Size() != totalSize {
logger.Debugf("assembled file size mismatch: got %d want %d", info.Size(), totalSize)
_ = os.Remove(tempFilePath)
return http.StatusBadRequest, fmt.Errorf("upload incomplete: expected %d bytes, received %d", totalSize, info.Size())
}
// close file before moving
outFile.Close()
// Move the completed file from the temp location to the final destination
@@ -879,6 +936,10 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
}
reconcileSharesAfterMove(false, source, source, tempFilePath, realPath)
activity.RecordUpload(r, toActor(d), source, path, false)
activeUploadSessions.release(realPath, sessionID)
}
if err = writeChunkUploadResponse(w, assembled, totalSize, assembled == totalSize); err != nil {
logger.Debugf("could not write chunk upload response: %v", err)
}
return http.StatusOK, nil
}
@@ -893,12 +954,82 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
preview.DelThumbs(r.Context(), *fileInfo)
}
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
if !isContentUpload(r) {
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
if err != nil {
logger.Debugf("error writing file: %v", err)
return ErrToStatus(err), err
}
activity.RecordUpload(r, toActor(d), source, path, false)
return http.StatusOK, nil
}
totalSize, hasTotalSize, err := parseUploadTotalSize(r)
if err != nil {
logger.Debugf("%v", err)
return http.StatusBadRequest, err
}
// Write to a temp file, verify size, then move into place so truncated
// uploads never leave a corrupt destination file.
if err = os.MkdirAll(filepath.Dir(realPath), fileutils.EffectiveDirPerm()); err != nil {
logger.Debugf("could not create parent directory: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not create parent directory: %v", err)
}
sessionID, sessionErr := parseUploadSession(r)
if sessionErr != nil {
logger.Debugf("%v", sessionErr)
return http.StatusBadRequest, sessionErr
}
if acquireErr := activeUploadSessions.acquire(realPath, sessionID); acquireErr != nil {
if isUploadSessionConflict(acquireErr) {
logger.Debugf("%v", acquireErr)
return http.StatusConflict, acquireErr
}
logger.Debugf("%v", acquireErr)
return http.StatusInternalServerError, acquireErr
}
tempFilePath := uploadTempPath(realPath, sessionID)
outFile, err := os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fileutils.PermFile)
if err != nil {
logger.Debugf("could not open temp file: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not open temp file: %v", err)
}
written, copyErr := io.Copy(outFile, r.Body)
closeErr := outFile.Close()
if copyErr != nil {
_ = os.Remove(tempFilePath)
activeUploadSessions.release(realPath, sessionID)
logger.Debugf("error writing file: %v", copyErr)
return ErrToStatus(copyErr), copyErr
}
if closeErr != nil {
_ = os.Remove(tempFilePath)
activeUploadSessions.release(realPath, sessionID)
logger.Debugf("error closing temp file: %v", closeErr)
return http.StatusInternalServerError, closeErr
}
if err = validateReceivedBytes(written, totalSize, hasTotalSize, r.ContentLength); err != nil {
_ = os.Remove(tempFilePath)
activeUploadSessions.release(realPath, sessionID)
logger.Debugf("%v", err)
return http.StatusBadRequest, err
}
err = files.MoveResource(false, source, source, tempFilePath, realPath)
if err != nil {
_ = os.Remove(tempFilePath)
activeUploadSessions.release(realPath, sessionID)
logger.Debugf("error writing file: %v", err)
return ErrToStatus(err), err
}
reconcileSharesAfterMove(false, source, source, tempFilePath, realPath)
activity.RecordUpload(r, toActor(d), source, path, false)
activeUploadSessions.release(realPath, sessionID)
return http.StatusOK, nil
}
@@ -984,7 +1115,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
fullIndexPath := utils.JoinScopedIndexPath(userScope, path)
// Check access control for the target path
idx := indexing.GetIndex(source)
if idx == nil {
@@ -1038,7 +1169,7 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return http.StatusBadRequest, err
}
resolvedPath := utils.JoinPathAsUnix(d.Share.Path, cleanPath)
resolvedPath := utils.JoinScopedIndexPath(d.Share.Path, cleanPath)
err = files.WriteFile(sourceName, resolvedPath, r.Body)
if err != nil {
logger.Errorf("public put handler: error updating resource with error %v", err)
@@ -1090,9 +1221,11 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
// Process each item
for _, item := range req.Items {
clientFromPath := item.FromPath
clientToPath := item.ToPath
// Validate all fields are provided
if item.FromSource == "" || item.FromPath == "" || item.ToSource == "" || item.ToPath == "" {
if item.FromSource == "" || clientFromPath == "" || item.ToSource == "" || clientToPath == "" {
item.Message = "fromSource, fromPath, toSource, and toPath are required"
if d.Share.Hash != "" {
response.Failed = append(response.Failed, MoveCopyItem{
@@ -1100,23 +1233,21 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
cleanFromPath, err := utils.SanitizePath(item.FromPath)
cleanFromPath, err := utils.SanitizePath(clientFromPath)
if err != nil {
item.Message = fmt.Sprintf("invalid fromPath: %v", err)
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
cleanToPath, err := utils.SanitizePath(item.ToPath)
cleanToPath, err := utils.SanitizePath(clientToPath)
if err != nil {
item.Message = fmt.Sprintf("invalid toPath: %v", err)
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
item.FromPath = cleanFromPath
item.ToPath = cleanToPath
// Get user scopes for both sources
// For shares, paths are already absolute, so use empty scope
@@ -1126,19 +1257,19 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
userscopeSrc, err = d.User.GetScopeForSourceName(item.FromSource)
if err != nil {
item.Message = "source not available"
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
userscopeDst, err = d.User.GetScopeForSourceName(item.ToSource)
if err != nil {
item.Message = "destination source not available"
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
fromPerms, permErr := effectiveFilePerms(d, item.FromSource)
if permErr != nil {
item.Message = "permission denied"
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
toPerms := fromPerms
@@ -1147,13 +1278,13 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
toPerms, toErr = effectiveFilePerms(d, item.ToSource)
if toErr != nil {
item.Message = "permission denied"
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
}
if msg := resourcePatchPermCheck(req.Action, item.FromSource, item.ToSource, fromPerms, toPerms); msg != "" {
item.Message = msg
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
}
@@ -1168,7 +1299,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
@@ -1182,7 +1313,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
@@ -1194,7 +1325,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
@@ -1206,16 +1337,16 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
// Build full index paths for access control
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
fullSrcIndexPath := utils.JoinScopedIndexPath(userscopeSrc, cleanFromPath)
fullDstIndexPath := utils.JoinScopedIndexPath(userscopeDst, cleanToPath)
if fullDstIndexPath == "/" || fullSrcIndexPath == "/" {
item.Message = "source or destination is the root or unautharized directory"
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
@@ -1228,7 +1359,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
if !state.AccessPermitted(dstIdx.Path, utils.IndexPathFromNormalized(fullDstIndexPath, true), d.User.Username) {
@@ -1239,16 +1370,14 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
// Get real paths
// Combine user scope with item paths BEFORE calling GetRealPath to avoid double scope application
fullSrcPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcPath)
// Get real paths (fullSrcIndexPath already includes user scope)
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcIndexPath)
if err != nil {
logger.Errorf("could not resolve source path: %v, item.FromPath: %v", err, item.FromPath)
logger.Errorf("could not resolve source path: %v, fromPath: %v", err, clientFromPath)
item.Message = "could not resolve source path"
if d.Share.Hash != "" {
response.Failed = append(response.Failed, MoveCopyItem{
@@ -1256,13 +1385,13 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
// Check destination parent directory exists
dstParentPath := filepath.Dir(item.ToPath)
fullDstParentPath := utils.JoinPathAsUnix(userscopeDst, dstParentPath)
dstParentPath := filepath.Dir(cleanToPath)
fullDstParentPath := utils.JoinScopedIndexPath(userscopeDst, dstParentPath)
parentDir, _, err := dstIdx.GetRealPath(fullDstParentPath)
if err != nil {
item.Message = "destination directory does not exist"
@@ -1272,10 +1401,10 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
realDest := parentDir + "/" + filepath.Base(item.ToPath)
realDest := parentDir + "/" + filepath.Base(cleanToPath)
// Auto-rename if requested
if req.Rename {
@@ -1292,7 +1421,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
}
@@ -1317,13 +1446,13 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
continue
}
item.Message = err.Error()
response.Failed = append(response.Failed, item)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
continue
}
// Success
response.Succeeded = append(response.Succeeded, item)
activity.RecordPatchItem(r, toActor(d), req.Action, activity.MoveCopyItem{FromSource: item.FromSource, FromPath: item.FromPath, ToPath: item.ToPath})
response.Succeeded = append(response.Succeeded, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
activity.RecordPatchItem(r, toActor(d), req.Action, activity.MoveCopyItem{FromSource: item.FromSource, FromPath: clientFromPath, ToPath: clientToPath})
}
if len(response.Failed) == 0 && len(response.Succeeded) == 0 {
@@ -1411,13 +1540,13 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
return http.StatusBadRequest, fmt.Errorf("invalid from path: %w", err)
}
req.Items[i].FromSource = sourceName
req.Items[i].FromPath = utils.JoinPathAsUnix(d.Share.Path, sanitizedPath)
req.Items[i].FromPath = utils.JoinScopedIndexPath(d.Share.Path, sanitizedPath)
sanitizedPath, err = utils.SanitizePath(req.Items[i].ToPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid to path: %w", err)
}
req.Items[i].ToSource = sourceName
req.Items[i].ToPath = utils.JoinPathAsUnix(d.Share.Path, sanitizedPath)
req.Items[i].ToPath = utils.JoinScopedIndexPath(d.Share.Path, sanitizedPath)
}
d.Data = req
@@ -1595,6 +1724,12 @@ func publicItemsGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (
return RenderJSON(w, r, items)
}
func moveCopyWithClientPaths(item MoveCopyItem, clientFrom, clientTo string) MoveCopyItem {
item.FromPath = clientFrom
item.ToPath = clientTo
return item
}
func bulkDeleteActivityItems(items []BulkDeleteItem) []activity.BulkDeleteItem {
out := make([]activity.BulkDeleteItem, len(items))
for i, it := range items {
@@ -0,0 +1,34 @@
package web
import (
"net/http"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
)
func TestCheckPermissionsMissingParamsStatus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
opts utils.FileOptions
}{
{name: "missing path", opts: utils.FileOptions{Source: "default"}},
{name: "missing source", opts: utils.FileOptions{Path: "/"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := files.CheckPermissions(tt.opts, &users.User{})
if err == nil {
t.Fatal("expected an error")
}
if status := ErrToStatus(err); status != http.StatusBadRequest {
t.Fatalf("status=%d err=%v, want %d", status, err, http.StatusBadRequest)
}
})
}
}
@@ -0,0 +1,611 @@
package web
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
var uploadTestResolverMu sync.Mutex
func setupUploadHTTPTest(t *testing.T) (root string, user *users.User) {
t.Helper()
uploadTestResolverMu.Lock()
prevFilePerm := fileutils.PermFile
prevDirPerm := fileutils.PermDir
prevSourceMap := settings.Config.Server.SourceMap
prevNameToSource := settings.Config.Server.NameToSource
prevSourceResolver := users.GetSourceNameResolver()
prevSourceConfig := users.GetSourceConfig()
t.Cleanup(func() {
indexing.ClearTestIndices()
fileutils.SetFsPermissions(uint32(prevFilePerm), uint32(prevDirPerm))
settings.Config.Server.SourceMap = prevSourceMap
settings.Config.Server.NameToSource = prevNameToSource
users.SetSourceNameResolver(prevSourceResolver)
users.SetSourceConfig(prevSourceConfig)
uploadTestResolverMu.Unlock()
})
fileutils.SetFsPermissions(0o644, 0o755)
root = t.TempDir()
sourceName := "uploads"
sourcePath := root
indexing.SetTestIndex(sourceName, sourcePath)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatal("expected test index")
}
idx.Config.ResolvedRules.IndexingDisabled = true
settings.Config.Server.SourceMap = map[string]*settings.Source{
sourcePath: {Path: sourcePath, Name: sourceName},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
sourceName: settings.Config.Server.SourceMap[sourcePath],
}
users.SetSourceNameResolver(func(name string) (string, error) {
if name == sourceName {
return sourcePath, nil
}
return "", fmt.Errorf("unknown source %q", name)
})
users.SetSourceConfig(&users.SourceConfigProvider{
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
if path == sourcePath {
return users.SourceInfo{Path: sourcePath, Name: sourceName}, true
}
return users.SourceInfo{}, false
},
GetSourceByName: func(name string) (users.SourceInfo, bool) {
if name == sourceName {
return users.SourceInfo{Path: sourcePath, Name: sourceName}, true
}
return users.SourceInfo{}, false
},
})
perms := users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
}
user = &users.User{
ID: 1,
FrontendUser: users.FrontendUser{
Username: "uploader",
},
BackendScopes: []users.BackendScope{
{Path: sourcePath, Scope: "/", Permissions: perms},
},
BackendSourcePermissions: map[string]users.SourceFilePermissions{
sourcePath: perms,
},
Version: users.SourcePermissionsMigrationVersion,
}
return root, user
}
func postUpload(
t *testing.T,
user *users.User,
path string,
body io.Reader,
contentLength int64,
headers map[string]string,
) (int, error) {
t.Helper()
url := "/api/resources?source=uploads&path=" + path
req := httptest.NewRequest(http.MethodPost, url, body)
if contentLength >= 0 {
req.ContentLength = contentLength
}
if headers == nil {
headers = map[string]string{}
}
if _, ok := headers["X-File-Upload-Session"]; !ok {
headers["X-File-Upload-Session"] = "test-session"
}
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
return ResourcePostHandler(rec, req, &requestContext{User: user})
}
func TestParseUploadTotalSize(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, "/", nil)
total, ok, err := parseUploadTotalSize(req)
if err != nil || ok || total != 0 {
t.Fatalf("empty header: total=%d ok=%v err=%v", total, ok, err)
}
req.Header.Set("X-File-Total-Size", "12345")
total, ok, err = parseUploadTotalSize(req)
if err != nil || !ok || total != 12345 {
t.Fatalf("valid header: total=%d ok=%v err=%v", total, ok, err)
}
req.Header.Set("X-File-Total-Size", "nope")
_, _, err = parseUploadTotalSize(req)
if err == nil {
t.Fatal("expected parse error")
}
}
func TestValidateReceivedBytes(t *testing.T) {
t.Parallel()
if err := validateReceivedBytes(10, 10, true, 10); err != nil {
t.Fatalf("matching sizes: %v", err)
}
if err := validateReceivedBytes(9, 10, true, -1); err == nil {
t.Fatal("expected total size mismatch")
}
if err := validateReceivedBytes(9, 0, false, 10); err == nil {
t.Fatal("expected content-length mismatch")
}
if err := validateReceivedBytes(10, 0, false, -1); err != nil {
t.Fatalf("no expected sizes: %v", err)
}
}
func TestIsContentUpload(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, "/", nil)
if isContentUpload(req) {
t.Fatal("empty create request should not be a content upload")
}
req.Header.Set("X-File-Total-Size", "0")
if !isContentUpload(req) {
t.Fatal("expected total-size header to mark content upload")
}
req = httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("X-File-Upload-Session", "abc")
if !isContentUpload(req) {
t.Fatal("expected upload session header to mark content upload")
}
}
func TestValidateChunkBounds(t *testing.T) {
t.Parallel()
if err := validateChunkBounds(0, 10, 100); err != nil {
t.Fatalf("valid bounds: %v", err)
}
if err := validateChunkBounds(-1, 10, 100); err == nil {
t.Fatal("expected negative offset error")
}
if err := validateChunkBounds(0, 10, -1); err == nil {
t.Fatal("expected negative total size error")
}
if err := validateChunkBounds(101, 1, 100); err == nil {
t.Fatal("expected offset exceeds total error")
}
if err := validateChunkBounds(90, 20, 100); err == nil {
t.Fatal("expected chunk exceeds remaining error")
}
if err := validateChunkBounds(math.MaxInt64, 1, math.MaxInt64); err == nil {
t.Fatal("expected offset overflow error")
}
if err := validateAssembledSize(90, 20, 100); err == nil {
t.Fatal("expected assembled size error")
}
if err := validateAssembledSize(0, -1, 100); err == nil {
t.Fatal("expected negative chunk size error")
}
}
func TestParseUploadSession(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodPost, "/", nil)
if _, err := parseUploadSession(req); err == nil {
t.Fatal("expected missing session error")
}
req.Header.Set(uploadSessionHeader, "valid-Session_123")
got, err := parseUploadSession(req)
if err != nil || got != "valid-Session_123" {
t.Fatalf("valid session: got=%q err=%v", got, err)
}
req.Header.Set(uploadSessionHeader, "bad token!")
if _, err = parseUploadSession(req); err == nil {
t.Fatal("expected invalid character error")
}
req.Header.Set(uploadSessionHeader, strings.Repeat("a", 129))
if _, err = parseUploadSession(req); err == nil {
t.Fatal("expected oversized session error")
}
}
func TestUploadTempPathStable(t *testing.T) {
t.Parallel()
a := uploadTempPath("/data/photo.jpg", "session-a")
b := uploadTempPath("/data/photo.jpg", "session-a")
if a != b {
t.Fatalf("expected stable temp path, got %q and %q", a, b)
}
if a == uploadTempPath("/data/photo.jpg", "session-b") {
t.Fatal("expected different sessions to use different temp paths")
}
if filepath.Ext(a) != ".tmp" {
t.Fatalf("expected .tmp suffix, got %q", a)
}
}
func TestResourcePostHandler_CreateEmptyFileWithoutSession(t *testing.T) {
root, user := setupUploadHTTPTest(t)
status, err := postUpload(t, user, "/empty.txt", bytes.NewReader(nil), 0, map[string]string{
"X-File-Upload-Session": "",
})
if status != http.StatusOK || err != nil {
t.Fatalf("status=%d err=%v", status, err)
}
got, err := os.ReadFile(filepath.Join(root, "empty.txt"))
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("expected empty file, got %q", got)
}
}
func TestResourcePostHandler_NonChunkedMatchesTotalSize(t *testing.T) {
root, user := setupUploadHTTPTest(t)
body := []byte("hello-image-bytes")
status, err := postUpload(t, user, "/photo.jpg", bytes.NewReader(body), int64(len(body)), map[string]string{
"X-File-Total-Size": strconv.Itoa(len(body)),
})
if status != http.StatusOK || err != nil {
t.Fatalf("status=%d err=%v", status, err)
}
got, err := os.ReadFile(filepath.Join(root, "photo.jpg"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, body) {
t.Fatalf("file contents mismatch: got %q", got)
}
if matches, _ := filepath.Glob(filepath.Join(root, "*.uploading.tmp")); len(matches) != 0 {
t.Fatalf("expected no leftover temp files, got %v", matches)
}
}
func TestResourcePostHandler_NonChunkedTruncatedBody(t *testing.T) {
root, user := setupUploadHTTPTest(t)
body := []byte("partial")
status, err := postUpload(t, user, "/photo.jpg", bytes.NewReader(body), int64(len(body)), map[string]string{
"X-File-Total-Size": "100",
})
if status != http.StatusBadRequest || err == nil {
t.Fatalf("status=%d err=%v, want 400", status, err)
}
if _, err := os.Stat(filepath.Join(root, "photo.jpg")); !os.IsNotExist(err) {
t.Fatalf("expected no final file, stat err=%v", err)
}
if matches, _ := filepath.Glob(filepath.Join(root, "*.uploading.tmp")); len(matches) != 0 {
t.Fatalf("expected temp removed, got %v", matches)
}
}
func TestResourcePostHandler_ChunkedTwoChunksComplete(t *testing.T) {
root, user := setupUploadHTTPTest(t)
part1 := []byte("aaaa")
part2 := []byte("bbbb")
total := len(part1) + len(part2)
status, err := postUpload(t, user, "/big.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
"X-File-Chunk-Offset": "0",
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk1 status=%d err=%v", status, err)
}
if _, err = os.Stat(filepath.Join(root, "big.bin")); !os.IsNotExist(err) {
t.Fatal("final file should not exist after first chunk")
}
status, err = postUpload(t, user, "/big.bin", bytes.NewReader(part2), int64(len(part2)), map[string]string{
"X-File-Chunk-Offset": strconv.Itoa(len(part1)),
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk2 status=%d err=%v", status, err)
}
got, err := os.ReadFile(filepath.Join(root, "big.bin"))
if err != nil {
t.Fatal(err)
}
want := append(append([]byte{}, part1...), part2...)
if !bytes.Equal(got, want) {
t.Fatalf("assembled mismatch: got %q want %q", got, want)
}
}
func TestResourcePostHandler_ChunkedShortChunkContentLength(t *testing.T) {
root, user := setupUploadHTTPTest(t)
part1 := []byte("12345678")
total := 16
status, err := postUpload(t, user, "/short.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
"X-File-Chunk-Offset": "0",
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk1 status=%d err=%v", status, err)
}
short := []byte("xx")
status, err = postUpload(t, user, "/short.bin", bytes.NewReader(short), 8, map[string]string{
"X-File-Chunk-Offset": strconv.Itoa(len(part1)),
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusBadRequest || err == nil {
t.Fatalf("status=%d err=%v, want 400", status, err)
}
realPath := filepath.Join(root, "short.bin")
tempPath := uploadTempPath(realPath, "test-session")
info, err := os.Stat(tempPath)
if err != nil {
t.Fatalf("expected temp preserved: %v", err)
}
if info.Size() != int64(len(part1)) {
t.Fatalf("temp size=%d, want %d", info.Size(), len(part1))
}
if _, err := os.Stat(realPath); !os.IsNotExist(err) {
t.Fatal("final file should not exist after short chunk")
}
}
type errAfterReader struct {
data []byte
err error
n int
}
func (r *errAfterReader) Read(p []byte) (int, error) {
if r.n >= len(r.data) {
return 0, r.err
}
n := copy(p, r.data[r.n:])
r.n += n
if r.n >= len(r.data) {
return n, r.err
}
return n, nil
}
func TestResourcePostHandler_ChunkedKeepsPartialOnBodyError(t *testing.T) {
root, user := setupUploadHTTPTest(t)
part1 := []byte("aaaaaaaa")
total := 16
status, err := postUpload(t, user, "/resume.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
"X-File-Chunk-Offset": "0",
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk1 status=%d err=%v", status, err)
}
failing := &errAfterReader{
data: []byte("bbb"),
err: errors.New("simulated disconnect"),
}
status, err = postUpload(t, user, "/resume.bin", failing, 8, map[string]string{
"X-File-Chunk-Offset": strconv.Itoa(len(part1)),
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusInternalServerError || err == nil {
t.Fatalf("status=%d err=%v, want 500", status, err)
}
realPath := filepath.Join(root, "resume.bin")
tempPath := uploadTempPath(realPath, "test-session")
info, err := os.Stat(tempPath)
if err != nil {
t.Fatalf("expected temp preserved: %v", err)
}
if info.Size() != int64(len(part1)) {
t.Fatalf("temp size=%d, want truncated to %d", info.Size(), len(part1))
}
// Resume with a full second chunk.
part2 := []byte("bbbbbbbb")
status, err = postUpload(t, user, "/resume.bin", bytes.NewReader(part2), int64(len(part2)), map[string]string{
"X-File-Chunk-Offset": strconv.Itoa(len(part1)),
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusOK || err != nil {
t.Fatalf("resume status=%d err=%v", status, err)
}
got, err := os.ReadFile(realPath)
if err != nil {
t.Fatal(err)
}
want := append(append([]byte{}, part1...), part2...)
if !bytes.Equal(got, want) {
t.Fatalf("resumed contents mismatch: got %q want %q", got, want)
}
}
func TestResourcePostHandler_ChunkOffsetExceedsTotal(t *testing.T) {
_, user := setupUploadHTTPTest(t)
body := []byte("data")
status, err := postUpload(t, user, "/big.bin", bytes.NewReader(body), int64(len(body)), map[string]string{
"X-File-Chunk-Offset": "200",
"X-File-Total-Size": "100",
})
if status != http.StatusBadRequest || err == nil {
t.Fatalf("status=%d err=%v, want 400", status, err)
}
}
func TestResourcePostHandler_ConflictingUploadSessions(t *testing.T) {
root, user := setupUploadHTTPTest(t)
part1 := []byte("aaaa")
total := 8
status, err := postUpload(t, user, "/race.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
"X-File-Upload-Session": "session-a",
"X-File-Chunk-Offset": "0",
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk1 status=%d err=%v", status, err)
}
status, err = postUpload(t, user, "/race.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
"X-File-Upload-Session": "session-b",
"X-File-Chunk-Offset": "0",
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusConflict || err == nil {
t.Fatalf("status=%d err=%v, want 409", status, err)
}
// Non-chunked upload should also conflict with an active chunked session.
status, err = postUpload(t, user, "/race.bin", bytes.NewReader([]byte("tiny")), 4, map[string]string{
"X-File-Upload-Session": "session-c",
"X-File-Total-Size": "4",
})
if status != http.StatusConflict || err == nil {
t.Fatalf("non-chunked status=%d err=%v, want 409", status, err)
}
// Original session can still resume.
part2 := []byte("bbbb")
status, err = postUpload(t, user, "/race.bin", bytes.NewReader(part2), int64(len(part2)), map[string]string{
"X-File-Upload-Session": "session-a",
"X-File-Chunk-Offset": strconv.Itoa(len(part1)),
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusOK || err != nil {
t.Fatalf("resume status=%d err=%v", status, err)
}
got, err := os.ReadFile(filepath.Join(root, "race.bin"))
if err != nil {
t.Fatal(err)
}
want := append(append([]byte{}, part1...), part2...)
if !bytes.Equal(got, want) {
t.Fatalf("assembled mismatch: got %q want %q", got, want)
}
}
func TestResourcePostHandler_ExpiredSessionAllowsNewUpload(t *testing.T) {
root, user := setupUploadHTTPTest(t)
part1 := []byte("aaaa")
total := 8
realPath := filepath.Join(root, "stale.bin")
status, err := postUpload(t, user, "/stale.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
"X-File-Upload-Session": "session-old",
"X-File-Chunk-Offset": "0",
"X-File-Total-Size": strconv.Itoa(total),
})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk1 status=%d err=%v", status, err)
}
// Abandoned session still blocks until the lease expires.
status, err = postUpload(t, user, "/stale.bin", bytes.NewReader([]byte("fullbody")), 8, map[string]string{
"X-File-Upload-Session": "session-new",
"X-File-Total-Size": "8",
})
if status != http.StatusConflict || err == nil {
t.Fatalf("status=%d err=%v, want 409 while lease active", status, err)
}
activeUploadSessions.expireForTest(realPath)
body := []byte("replaced!")
status, err = postUpload(t, user, "/stale.bin", bytes.NewReader(body), int64(len(body)), map[string]string{
"X-File-Upload-Session": "session-new",
"X-File-Total-Size": strconv.Itoa(len(body)),
})
if status != http.StatusOK || err != nil {
t.Fatalf("after expiry status=%d err=%v", status, err)
}
got, err := os.ReadFile(realPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, body) {
t.Fatalf("got %q want %q", got, body)
}
}
func TestResourcePostHandler_PartialChunkResponse(t *testing.T) {
_, user := setupUploadHTTPTest(t)
part1 := []byte("aaaa")
total := 8
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/resources?source=uploads&path=/partial.bin", bytes.NewReader(part1))
req.ContentLength = int64(len(part1))
req.Header.Set("X-File-Upload-Session", "session-partial")
req.Header.Set("X-File-Chunk-Offset", "0")
req.Header.Set("X-File-Total-Size", strconv.Itoa(total))
status, err := ResourcePostHandler(rec, req, &requestContext{User: user})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk1 status=%d err=%v", status, err)
}
if rec.Header().Get("Connection") != "close" {
t.Fatalf("expected Connection: close, got %q", rec.Header().Get("Connection"))
}
var payload chunkUploadResponse
if decodeErr := json.NewDecoder(rec.Body).Decode(&payload); decodeErr != nil {
t.Fatalf("decode response: %v", decodeErr)
}
if payload.Complete || payload.Offset != int64(len(part1)) || payload.Total != int64(total) {
t.Fatalf("unexpected payload: %+v", payload)
}
part2 := []byte("bbbb")
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/api/resources?source=uploads&path=/partial.bin", bytes.NewReader(part2))
req2.ContentLength = int64(len(part2))
req2.Header.Set("X-File-Upload-Session", "session-partial")
req2.Header.Set("X-File-Chunk-Offset", strconv.Itoa(len(part1)))
req2.Header.Set("X-File-Total-Size", strconv.Itoa(total))
status, err = ResourcePostHandler(rec2, req2, &requestContext{User: user})
if status != http.StatusOK || err != nil {
t.Fatalf("chunk2 status=%d err=%v", status, err)
}
var finalPayload chunkUploadResponse
if decodeErr := json.NewDecoder(rec2.Body).Decode(&finalPayload); decodeErr != nil {
t.Fatalf("decode final response: %v", decodeErr)
}
if !finalPayload.Complete || finalPayload.Offset != int64(total) {
t.Fatalf("unexpected final payload: %+v", finalPayload)
}
}
+122
View File
@@ -0,0 +1,122 @@
package web
import (
"net/http"
"strings"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSessionCookie_HttpOnlyAndSecureOnHTTPS(t *testing.T) {
prev := settings.Config.Http.TrustProxyHeaders
settings.Config.Http.TrustProxyHeaders = true
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = prev })
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Forwarded-Proto", "https")
req.Host = "example.com"
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
if !c.HttpOnly {
t.Fatal("expected HttpOnly session cookie")
}
if !c.Secure {
t.Fatal("expected Secure session cookie over HTTPS")
}
if c.Name != sessionCookieName {
t.Fatalf("cookie name: got %q", c.Name)
}
}
func TestSessionCookie_NotSecureOnHTTP(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "http://localhost/", nil)
if err != nil {
t.Fatal(err)
}
req.Host = "localhost"
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
if !c.HttpOnly {
t.Fatal("expected HttpOnly session cookie")
}
if c.Secure {
t.Fatal("did not expect Secure cookie on plain HTTP")
}
}
func TestSpaContentSecurityPolicy_BlocksUntrustedScripts(t *testing.T) {
prev := settings.Config.Integrations.OnlyOffice
settings.Config.Integrations.OnlyOffice = settings.OnlyOffice{}
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
csp := spaContentSecurityPolicy("testnonce")
if csp != "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com" {
t.Fatalf("unexpected CSP: %s", csp)
}
if strings.Contains(csp, "'unsafe-inline'") {
t.Fatal("script-src must not allow unsafe-inline (srcdoc XSS)")
}
if strings.Contains(csp, "default-src") {
t.Fatal("CSP should only restrict script-src to avoid breaking frames, blobs, and external assets")
}
}
func TestSpaContentSecurityPolicy_IncludesOnlyOfficeOrigins(t *testing.T) {
prev := settings.Config.Integrations.OnlyOffice
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
tests := []struct {
name string
oo settings.OnlyOffice
want string
}{
{
name: "url only",
oo: settings.OnlyOffice{Url: "http://localhost:9052"},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
},
{
name: "internal url only",
oo: settings.OnlyOffice{InternalUrl: "http://onlyoffice-internal:80"},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://onlyoffice-internal:80",
},
{
name: "both urls",
oo: settings.OnlyOffice{
Url: "https://office.example.com",
InternalUrl: "http://onlyoffice-internal",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com https://office.example.com http://onlyoffice-internal",
},
{
name: "duplicate origins",
oo: settings.OnlyOffice{
Url: "http://localhost:9052",
InternalUrl: "http://localhost:9052",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
},
{
name: "invalid urls ignored",
oo: settings.OnlyOffice{
Url: "not-a-url",
InternalUrl: "://bad",
},
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings.Config.Integrations.OnlyOffice = tt.oo
if got := spaContentSecurityPolicy("testnonce"); got != tt.want {
t.Fatalf("unexpected CSP:\ngot: %s\nwant: %s", got, tt.want)
}
})
}
}
+3 -1
View File
@@ -280,6 +280,9 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if getErr != nil {
return http.StatusBadRequest, fmt.Errorf("share not found")
}
if !beforeShare.UserCanEdit(d.User) {
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
}
updateSourceName := beforeShare.GetSourceName()
if updateSourceName != "" {
ownerPerms, permErr := d.User.FilePermsForSourceName(updateSourceName)
@@ -307,7 +310,6 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
link.SourcePath = preservedSourcePath
link.PinnedItems = preservedPinned
link.Version = preservedVersion
link.UserID = d.User.ID
if link.ShareType == "upload" && !req.AllowCreate {
link.AllowCreate = true
}
+186
View File
@@ -0,0 +1,186 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
const victimShareHash = "victim_hash_test_01"
func setupShareAuthTestUsers(t *testing.T) (owner, attacker, admin *users.User) {
t.Helper()
setupTestEnv(t)
fullPerms := users.SourceFilePermissions{
View: true, Download: true, Modify: true, Create: true, Delete: true,
}
shareScope := users.BackendScope{Path: "/srv", Scope: "/", Permissions: fullPerms}
backendPerms := map[string]users.SourceFilePermissions{"/srv": fullPerms}
owner = &users.User{
FrontendUser: users.FrontendUser{
Username: "share_owner",
Permissions: users.Permissions{Share: true},
},
BackendScopes: []users.BackendScope{shareScope},
BackendSourcePermissions: backendPerms,
Version: users.SourcePermissionsMigrationVersion,
}
attacker = &users.User{
FrontendUser: users.FrontendUser{
Username: "share_attacker",
Permissions: users.Permissions{Share: true},
},
BackendScopes: []users.BackendScope{shareScope},
BackendSourcePermissions: backendPerms,
Version: users.SourcePermissionsMigrationVersion,
}
admin = &users.User{
FrontendUser: users.FrontendUser{
Username: "share_admin",
Permissions: users.Permissions{Admin: true, Share: true},
},
BackendScopes: []users.BackendScope{shareScope},
BackendSourcePermissions: backendPerms,
Version: users.SourcePermissionsMigrationVersion,
}
for _, u := range []*users.User{owner, attacker, admin} {
if err := state.CreateUser(u, ""); err != nil {
t.Fatalf("CreateUser(%s): %v", u.Username, err)
}
}
adminFetched, err := state.GetUserByUsername("share_admin")
if err != nil {
t.Fatalf("GetUserByUsername(share_admin): %v", err)
}
if err := state.UpdateUser(&users.User{
ID: adminFetched.ID,
FrontendUser: users.FrontendUser{
Permissions: users.Permissions{Admin: true, Share: true},
},
}, "", "permissions"); err != nil {
t.Fatalf("set admin permissions: %v", err)
}
for _, pair := range []struct {
name string
ptr **users.User
}{
{"share_owner", &owner},
{"share_attacker", &attacker},
{"share_admin", &admin},
} {
got, err := state.GetUserByUsername(pair.name)
if err != nil {
t.Fatalf("GetUserByUsername(%s): %v", pair.name, err)
}
*pair.ptr = &got
}
return owner, attacker, admin
}
func createVictimShare(t *testing.T, ownerID uint64) {
t.Helper()
victim := &share.Share{
ShareSettings: share.ShareSettings{
FrontendShareInfo: share.FrontendShareInfo{ShareType: "normal"},
ShareLimits: share.ShareLimits{SourceName: "srv"},
},
ShareColumns: share.ShareColumns{
Hash: victimShareHash,
Path: "/",
},
SourcePath: "/srv",
UserID: ownerID,
Version: 1,
}
if err := state.CreateShare(victim); err != nil {
t.Fatalf("CreateShare: %v", err)
}
}
func postShareUpdate(t *testing.T, user *users.User, hash string, allowDelete bool) (int, error) {
t.Helper()
raw, err := json.Marshal(map[string]interface{}{
"hash": hash,
"allowDelete": allowDelete,
})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/share", bytes.NewReader(raw))
rec := httptest.NewRecorder()
return sharePostHandler(rec, req, &Context{User: user})
}
func TestSharePostUpdate_RejectNonOwner(t *testing.T) {
owner, attacker, _ := setupShareAuthTestUsers(t)
createVictimShare(t, owner.ID)
status, err := postShareUpdate(t, attacker, victimShareHash, true)
if status != http.StatusForbidden {
t.Fatalf("expected 403, got status=%d err=%v", status, err)
}
got, err := state.GetShare(victimShareHash)
if err != nil {
t.Fatalf("GetShare: %v", err)
}
if got.AllowDelete {
t.Fatal("non-owner update should not enable allowDelete")
}
if got.UserID != owner.ID {
t.Fatalf("UserID changed to %d, want owner %d", got.UserID, owner.ID)
}
}
func TestSharePostUpdate_AllowsOwner(t *testing.T) {
owner, _, _ := setupShareAuthTestUsers(t)
createVictimShare(t, owner.ID)
status, err := postShareUpdate(t, owner, victimShareHash, true)
if status != http.StatusOK {
t.Fatalf("expected 200, got status=%d err=%v", status, err)
}
got, err := state.GetShare(victimShareHash)
if err != nil {
t.Fatalf("GetShare: %v", err)
}
if !got.AllowDelete {
t.Fatal("owner update should enable allowDelete")
}
if got.UserID != owner.ID {
t.Fatalf("UserID changed to %d, want owner %d", got.UserID, owner.ID)
}
}
func TestSharePostUpdate_AdminCanEdit(t *testing.T) {
owner, _, admin := setupShareAuthTestUsers(t)
createVictimShare(t, owner.ID)
status, err := postShareUpdate(t, admin, victimShareHash, true)
if status != http.StatusOK {
t.Fatalf("expected 200, got status=%d err=%v", status, err)
}
got, err := state.GetShare(victimShareHash)
if err != nil {
t.Fatalf("GetShare: %v", err)
}
if !got.AllowDelete {
t.Fatal("admin update should enable allowDelete")
}
if got.UserID != owner.ID {
t.Fatalf("admin update should not reassign UserID: got %d want %d", got.UserID, owner.ID)
}
}
+69 -2
View File
@@ -2,6 +2,7 @@ package web
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io/fs"
@@ -42,7 +43,13 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return fmt.Errorf("error reloading template: %w", err)
}
}
// Set headers
nonce, err := utils.CSPNonceFromData(data)
if err != nil {
return err
}
// Inherited by srcdoc preview frames: nonce'd SPA scripts run; untrusted preview scripts do not.
w.Header().Set("Content-Security-Policy", spaContentSecurityPolicy(nonce))
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("X-Accel-Expires", "0")
@@ -51,6 +58,51 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
return templates.ExecuteTemplate(w, name, data)
}
// spaContentSecurityPolicy restricts only scripts. srcdoc preview frames inherit
// this header, blocking inline scripts without limiting frames, images, or API calls.
func spaContentSecurityPolicy(nonce string) string {
policy := fmt.Sprintf(
"script-src 'self' 'nonce-%s' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
nonce,
)
for _, origin := range onlyOfficeScriptSrcOrigins() {
policy += " " + origin
}
return policy
}
func onlyOfficeScriptSrcOrigins() []string {
oo := settings.Config.Integrations.OnlyOffice
candidates := []string{
cspOriginFromURL(oo.Url),
cspOriginFromURL(oo.InternalUrl),
}
var origins []string
seen := make(map[string]struct{}, len(candidates))
for _, origin := range candidates {
if origin == "" {
continue
}
if _, ok := seen[origin]; ok {
continue
}
seen[origin] = struct{}{}
origins = append(origins, origin)
}
return origins
}
func cspOriginFromURL(raw string) string {
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
w.Header().Set("Content-Type", contentType)
userSelectedTheme := ""
@@ -80,7 +132,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
externalLinks = newExternalLinks
}
defaultThemeColor := "#455a64"
defaultThemeColor := settings.DefaultBackgroundColor()
staticURL := settings.Config.Http.BaseURL + "public/static"
description := settings.Config.Frontend.Description
title := settings.Config.Frontend.Name
@@ -195,6 +247,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"minSearchLength": settings.Config.Server.MinSearchLength,
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
"darkMode": settings.Config.UserDefaults.UI.DarkMode,
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
"baseURL": settings.Config.Http.BaseURL,
"version": versionString,
"commitSHA": commitSHAString,
@@ -223,8 +277,20 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"loginButtonText": settings.Config.Frontend.LoginButtonText,
"passkeyAvailable": settings.Config.Auth.Methods.PasskeyAuth.Enabled,
"passkeyLoginButtonText": settings.Config.Auth.Methods.PasskeyAuth.LoginButtonText,
"disablePWAInstall": settings.Config.Frontend.DisablePWAInstall,
}
cspNonce, err := utils.CSPNonce()
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("csp nonce: %w", err)
}
globalVars, ok := data["globalVars"].(map[string]interface{})
if !ok {
return http.StatusInternalServerError, errors.New("unable to load global variables")
}
globalVars["cspNonce"] = cspNonce
data["cspNonce"] = cspNonce
// Marshal each variable to JSON strings for direct template usage
globalVarsJSON, err := json.Marshal(data["globalVars"])
if err != nil {
@@ -324,6 +390,7 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
assetPath = "img/icons/favicon.png"
case "icons/favicon-32x32.png",
"icons/pwa-icon-192.png", "icons/pwa-icon-256.png", "icons/pwa-icon-512.png",
"icons/pwa-icon-maskable-192.png", "icons/pwa-icon-maskable-512.png",
"icons/apple-touch-icon.png", "icons/mstile-256x256.png":
// Files are generated as PWAIconsCacheDir()/basename (URL uses icons/ prefix only for routing)
rel := strings.TrimPrefix(path, "icons/")
+21 -3
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
@@ -433,8 +434,25 @@ func ServeSingleFile(w http.ResponseWriter, r *http.Request, d *Context, source
return http.StatusForbidden, fmt.Errorf("access denied to path %s", scopedFilePath)
}
realPath, _, err := idx.GetRealPath(scopedFilePath)
var bound string
if d.Share.Hash != "" {
if d.Share.Path == "" {
return http.StatusForbidden, fmt.Errorf("share has no path scope")
}
bound = d.Share.Path
} else {
userScope, scopeErr := d.User.GetScopeForSourceName(source)
if scopeErr != nil || userScope == "" {
return http.StatusForbidden, fmt.Errorf("user has no access to source: %s", source)
}
bound = userScope
}
realPath, _, err := idx.GetRealPathScoped(bound, scopedFilePath)
if err != nil {
if errors.Is(err, liberrors.ErrPathEscapesScope) {
return http.StatusForbidden, err
}
return http.StatusInternalServerError, err
}
@@ -553,7 +571,7 @@ func streamHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
if err != nil {
return http.StatusForbidden, err
}
scopedPath := utils.JoinPathAsUnix(userscope, cleanPath)
scopedPath := utils.JoinScopedIndexPath(userscope, cleanPath)
return streamFilesHandler(w, r, d, source, []string{scopedPath})
}
@@ -600,7 +618,7 @@ func publicStreamHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
if !IsMediaStreamFile(shareRelativeDisplayName(d, cleanFile)) {
return http.StatusForbidden, fmt.Errorf("stream endpoint supports audio and video only")
}
scopedPath := utils.JoinPathAsUnix(d.Share.Path, cleanFile)
scopedPath := utils.JoinScopedIndexPath(d.Share.Path, cleanFile)
status, err := streamFilesHandler(w, r, d, sourceInfo.Name, []string{scopedPath})
if err != nil {
if status == http.StatusForbidden {
+88
View File
@@ -0,0 +1,88 @@
package web
import (
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
const uploadSessionHeader = "X-File-Upload-Session"
// uploadSessionTTL is how long an incomplete upload may keep exclusive claim on a path.
// Successful chunks refresh the lease; abandoned sessions expire and can be replaced.
var uploadSessionTTL = 30 * time.Minute
var errUploadSessionConflict = errors.New("conflicting upload session")
type uploadSessionEntry struct {
id string
lastSeen time.Time
}
type uploadSessionRegistry struct {
mu sync.Mutex
sessions map[string]uploadSessionEntry // realPath -> active session
}
var activeUploadSessions uploadSessionRegistry
func (r *uploadSessionRegistry) acquire(realPath, sessionID string) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.sessions == nil {
r.sessions = make(map[string]uploadSessionEntry)
}
now := time.Now()
if active, ok := r.sessions[realPath]; ok {
if now.Sub(active.lastSeen) > uploadSessionTTL {
delete(r.sessions, realPath)
} else if active.id != sessionID {
return fmt.Errorf("%w for %q", errUploadSessionConflict, realPath)
}
}
r.sessions[realPath] = uploadSessionEntry{id: sessionID, lastSeen: now}
return nil
}
func (r *uploadSessionRegistry) release(realPath, sessionID string) {
r.mu.Lock()
defer r.mu.Unlock()
if active, ok := r.sessions[realPath]; ok && active.id == sessionID {
delete(r.sessions, realPath)
}
}
// expireForTest marks a path's session as expired without deleting it (for unit tests).
func (r *uploadSessionRegistry) expireForTest(realPath string) {
r.mu.Lock()
defer r.mu.Unlock()
if active, ok := r.sessions[realPath]; ok {
active.lastSeen = time.Now().Add(-uploadSessionTTL - time.Second)
r.sessions[realPath] = active
}
}
func isUploadSessionConflict(err error) bool {
return errors.Is(err, errUploadSessionConflict)
}
// parseUploadSession reads X-File-Upload-Session. The value must be a short safe token.
func parseUploadSession(r *http.Request) (string, error) {
s := strings.TrimSpace(r.Header.Get(uploadSessionHeader))
if s == "" {
return "", fmt.Errorf("missing %s", uploadSessionHeader)
}
if len(s) > 128 {
return "", fmt.Errorf("invalid upload session")
}
for _, c := range s {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
continue
}
return "", fmt.Errorf("invalid upload session")
}
return s, nil
}
+124
View File
@@ -0,0 +1,124 @@
package web
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strconv"
)
// uploadTempPath returns the temporary path used while assembling an upload for realPath.
// sessionID isolates concurrent uploads targeting the same destination.
func uploadTempPath(realPath, sessionID string) string {
hasher := md5.New()
hasher.Write([]byte(realPath))
hasher.Write([]byte{0})
hasher.Write([]byte(sessionID))
return fmt.Sprintf("%s.%s.uploading.tmp", realPath, hex.EncodeToString(hasher.Sum(nil)))
}
// parseUploadTotalSize reads optional X-File-Total-Size.
// ok is false when the header is absent.
func parseUploadTotalSize(r *http.Request) (total int64, ok bool, err error) {
s := r.Header.Get("X-File-Total-Size")
if s == "" {
return 0, false, nil
}
total, err = strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, false, fmt.Errorf("invalid total size: %v", err)
}
if total < 0 {
return 0, false, fmt.Errorf("invalid total size: %d", total)
}
return total, true, nil
}
// validateChunkBounds rejects invalid offsets and sizes before writing a chunk.
// contentLength is the request Content-Length when known (>0).
func validateChunkBounds(offset, contentLength, totalSize int64) error {
if offset < 0 {
return fmt.Errorf("invalid chunk offset: %d", offset)
}
if totalSize < 0 {
return fmt.Errorf("invalid total size: %d", totalSize)
}
if offset > totalSize {
return fmt.Errorf("chunk offset exceeds total size: offset %d > %d", offset, totalSize)
}
remaining := totalSize - offset
if contentLength > 0 {
if contentLength > remaining {
return fmt.Errorf("chunk exceeds total size: offset %d + %d > %d", offset, contentLength, totalSize)
}
if offset > math.MaxInt64-contentLength {
return fmt.Errorf("chunk offset overflow")
}
}
return nil
}
// validateAssembledSize checks written chunk bytes against the declared total without overflow.
func validateAssembledSize(offset, chunkSize, totalSize int64) error {
if chunkSize < 0 {
return fmt.Errorf("invalid chunk size: %d", chunkSize)
}
if err := validateChunkBounds(offset, chunkSize, totalSize); err != nil {
return err
}
return nil
}
// validateReceivedBytes compares bytes written to optional declared totals.
// expectedTotal is checked only when hasExpected is true.
// contentLength is checked when greater than 0 (known Content-Length).
func validateReceivedBytes(written, expectedTotal int64, hasExpected bool, contentLength int64) error {
if hasExpected && written != expectedTotal {
return fmt.Errorf("upload incomplete: expected %d bytes, received %d", expectedTotal, written)
}
if contentLength > 0 && written != contentLength {
return fmt.Errorf("upload incomplete: expected %d bytes, received %d", contentLength, written)
}
return nil
}
// isContentUpload reports whether the request is uploading file bytes (as opposed to
// creating an empty file via POST with no upload metadata).
func isContentUpload(r *http.Request) bool {
if r.Header.Get("X-File-Chunk-Offset") != "" {
return true
}
if r.Header.Get(uploadSessionHeader) != "" {
return true
}
_, ok, err := parseUploadTotalSize(r)
return err == nil && ok
}
func drainRequestBody(r *http.Request) {
if r.Body == nil {
return
}
_, _ = io.Copy(io.Discard, r.Body)
_ = r.Body.Close()
}
type chunkUploadResponse struct {
Offset int64 `json:"offset"`
Total int64 `json:"total"`
Complete bool `json:"complete"`
}
func writeChunkUploadResponse(w http.ResponseWriter, offset, total int64, complete bool) error {
w.Header().Set("Connection", "close")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
return json.NewEncoder(w).Encode(chunkUploadResponse{
Offset: offset,
Total: total,
Complete: complete,
})
}
+28 -7
View File
@@ -4,8 +4,6 @@ import (
"encoding/json"
stderrors "errors"
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"io"
"net/http"
"net/url"
@@ -13,15 +11,17 @@ import (
"sort"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
type UserRequest struct {
@@ -43,9 +43,6 @@ type UserRequest struct {
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users [get]
func userGetHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
// since api self is used to validate a logged in user
w.Header().Add("X-Renew-Token", "false")
if strings.TrimSpace(r.URL.Query().Get("id")) != "" {
return http.StatusBadRequest, fmt.Errorf("query parameter id is not supported; use username=self for the current user or username=<login>")
}
@@ -276,6 +273,25 @@ func validatePatchWhich(which []string) error {
return nil
}
// validateNonAdminSelfPatchWhich rejects self-service PATCH attempts that touch privileged fields.
// Non-admins may only update NonAdminEditable profile fields, plus password (with X-Password) and otpEnabled.
func validateNonAdminSelfPatchWhich(which []string) error {
allowed := nonAdminEditableFieldNameSet()
for _, w := range which {
f := utils.CapitalizeFirst(strings.TrimSpace(w))
if f == "" {
return fmt.Errorf("which must not contain empty field names")
}
if strings.EqualFold(f, "Password") || strings.EqualFold(f, "OtpEnabled") {
continue
}
if _, ok := allowed[f]; !ok {
return fmt.Errorf("cannot update privileged user fields without admin permissions")
}
}
return nil
}
// userPutOnlyNonAdminEditableFields reports whether req.Which lists exclusively NonAdminEditable fields,
// excluding Password.
func userPutOnlyNonAdminEditableFields(which []string) bool {
@@ -376,6 +392,11 @@ func userPatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if !d.User.Permissions.Admin && targetUsername != d.User.Username {
return http.StatusForbidden, nil
}
if !d.User.Permissions.Admin && targetUsername == d.User.Username {
if err = validateNonAdminSelfPatchWhich(req.Which); err != nil {
return http.StatusForbidden, err
}
}
uValue, err2 := state.GetUserByUsername(targetUsername)
if err2 == errors.ErrNotExist {
+48
View File
@@ -53,3 +53,51 @@ func TestValidatePatchWhich(t *testing.T) {
}
})
}
func TestValidateNonAdminSelfPatchWhich(t *testing.T) {
t.Run("allows profile fields", func(t *testing.T) {
for _, which := range [][]string{
{"locale"},
{"darkMode", "viewMode"},
{"preview"},
{"sorting"},
} {
if err := validateNonAdminSelfPatchWhich(which); err != nil {
t.Fatalf("unexpected error for which=%v: %v", which, err)
}
}
})
t.Run("allows password and otpEnabled", func(t *testing.T) {
for _, which := range [][]string{
{"password"},
{"otpEnabled"},
{"locale", "password"},
} {
if err := validateNonAdminSelfPatchWhich(which); err != nil {
t.Fatalf("unexpected error for which=%v: %v", which, err)
}
}
})
t.Run("rejects privileged fields", func(t *testing.T) {
for _, which := range [][]string{
{"scopes"},
{"permissions"},
{"sourcePermissions"},
{"loginMethod"},
{"lockPassword"},
{"disableSettings"},
} {
if err := validateNonAdminSelfPatchWhich(which); err == nil {
t.Fatalf("expected error for which=%v", which)
}
}
})
t.Run("rejects mixed privileged and profile fields", func(t *testing.T) {
if err := validateNonAdminSelfPatchWhich([]string{"locale", "scopes"}); err == nil {
t.Fatal("expected error when which mixes profile and privileged fields")
}
})
}
+2 -2
View File
@@ -63,7 +63,7 @@ func viewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error
if err != nil {
return http.StatusForbidden, err
}
scopedPath := utils.JoinPathAsUnix(userscope, cleanPath)
scopedPath := utils.JoinScopedIndexPath(userscope, cleanPath)
return viewFilesHandler(w, r, d, source, []string{scopedPath})
}
@@ -107,7 +107,7 @@ func PublicViewHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
if err = ValidateViewGrant(token, d, ""); err != nil {
return http.StatusForbidden, err
}
scopedPath := utils.JoinPathAsUnix(d.Share.Path, cleanFile)
scopedPath := utils.JoinScopedIndexPath(d.Share.Path, cleanFile)
status, err := viewFilesHandler(w, r, d, sourceInfo.Name, []string{scopedPath})
if err != nil {
if status == http.StatusForbidden {
+2
View File
@@ -1,9 +1,11 @@
package main
import (
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/cmd"
)
func main() {
jwt.DecodeStrict = true
cmd.StartFilebrowser()
}
+97 -16
View File
@@ -428,6 +428,7 @@ type PathContext struct {
type FileInfoRequest struct {
IndexPath string
FollowSymlinks bool
BoundIndexPath string // index prefix for symlink containment (e.g. user scope)
ShowHidden bool
HideFileExt string // Hide files based on their extension
Expand bool // get child items for directories
@@ -435,10 +436,64 @@ type FileInfoRequest struct {
SkipExtendedAttrs bool // whether to skip extended attributes
}
// filesystemBound resolves the filesystem path for an index prefix under this source.
func (idx *Index) filesystemBound(boundIndexPath string) (string, error) {
bound := boundIndexPath
if bound == "" {
bound = "/"
}
fsPath := idx.Path
if bound != "/" {
fsPath = utils.JoinUnderSourceRoot(idx.Path, bound)
}
abs, err := filepath.Abs(fsPath)
if err != nil {
return "", err
}
return filepath.EvalSymlinks(abs)
}
// ensureResolvedContained rejects resolved filesystem paths outside the source or scope prefix.
func (idx *Index) ensureResolvedContained(realPath, boundIndexPath string) error {
sourceRoot, err := idx.filesystemBound("/")
if err != nil {
return err
}
if err := iteminfo.PathWithinRoot(sourceRoot, realPath); err != nil {
return errors.ErrPathEscapesScope
}
bound := boundIndexPath
if bound == "" {
bound = "/"
}
if bound != "/" {
scopeRoot, err := idx.filesystemBound(bound)
if err != nil {
return err
}
if err := iteminfo.PathWithinRoot(scopeRoot, realPath); err != nil {
return errors.ErrPathEscapesScope
}
}
return nil
}
// resolveSymlinksContained resolves symlinks and ensures the target stays within bounds.
func (idx *Index) resolveSymlinksContained(fsPath, boundIndexPath string) (string, error) {
resolved, err := filepath.EvalSymlinks(fsPath)
if err != nil {
return "", err
}
if err := idx.ensureResolvedContained(resolved, boundIndexPath); err != nil {
return "", err
}
return resolved, nil
}
// resolvePathContext resolves all path characteristics in a SINGLE stat call
// This eliminates duplicate stat/lstat calls and redundant isHidden/isSymlink checks
func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*PathContext, error) {
realPath := filepath.Join(idx.Path, indexPath)
func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool, boundIndexPath string) (*PathContext, error) {
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
// ONE filesystem stat call
fileInfo, err := os.Lstat(realPath)
@@ -450,7 +505,7 @@ func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*Pa
// Resolve symlink if requested
if isSymlink && followSymlinks {
realPath, err = filepath.EvalSymlinks(realPath)
realPath, err = idx.resolveSymlinksContained(realPath, boundIndexPath)
if err != nil {
return nil, err
}
@@ -506,7 +561,7 @@ func (idx *Index) evaluateIndexRules(ctx *PathContext, isRoutineScan bool) (isVi
// User permission checking happens in the API layer (FileInfoFaster)
func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
// 1. Resolve path context (single stat call)
ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks)
ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks, req.BoundIndexPath)
if err != nil {
return nil, err
}
@@ -594,7 +649,7 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
if !strings.HasSuffix(indexPath, "/") {
indexPath = indexPath + "/"
}
realPath := filepath.Join(idx.Path, indexPath)
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
// Open the directory
dir, err := os.Open(realPath)
@@ -632,10 +687,10 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
// GetFsInfoCore is the consolidated implementation for both GetFsInfo and GetFsInfoViewableOnly
func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileInfo, error) {
// Handle symlinks if not following them
realPath := filepath.Join(idx.Path, indexPath)
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
if opts.FollowSymlinks {
var err error
realPath, err = filepath.EvalSymlinks(realPath)
realPath, err = idx.resolveSymlinksContained(realPath, "/")
if err != nil {
return nil, err
}
@@ -991,10 +1046,28 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, indexPath strin
}
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
combined := append([]string{idx.Path}, relativePath...)
joinedPath := filepath.Join(combined...)
isDir, _ := IsDirCache.Get(joinedPath + ":isdir")
cached, ok := RealPathCache.Get(joinedPath)
return idx.getRealPathInternal("/", false, relativePath...)
}
// GetRealPathScoped resolves an index path and rejects symlink targets outside the source and bound prefix.
func (idx *Index) GetRealPathScoped(boundIndexPath string, relativePath ...string) (string, bool, error) {
return idx.getRealPathInternal(boundIndexPath, true, relativePath...)
}
func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool, relativePath ...string) (string, bool, error) {
parts := make([]string, 0, len(relativePath)+1)
parts = append(parts, idx.Path)
for _, p := range relativePath {
parts = append(parts, utils.CleanIndexPathSegment(p))
}
joinedPath := filepath.Join(parts...)
bound := boundIndexPath
if enforceScope && bound == "" {
bound = "/"
}
cacheKey := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", enforceScope, bound, joinedPath)
isDir, _ := IsDirCache.Get(cacheKey + ":isdir")
cached, ok := RealPathCache.Get(cacheKey)
if ok && cached != "" {
return cached, isDir, nil
}
@@ -1003,11 +1076,19 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
}
realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath)
if err == nil {
RealPathCache.Set(joinedPath, realPath)
IsDirCache.Set(joinedPath+":isdir", isDir)
if err != nil {
return realPath, isDir, err
}
return realPath, isDir, err
if enforceScope {
if err := idx.ensureResolvedContained(realPath, boundIndexPath); err != nil {
return "", false, err
}
} else if err := idx.ensureResolvedContained(realPath, "/"); err != nil {
return "", false, err
}
RealPathCache.Set(cacheKey, realPath)
IsDirCache.Set(cacheKey+":isdir", isDir)
return realPath, isDir, nil
}
func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
@@ -1034,7 +1115,7 @@ func (idx *Index) RefreshDirectory(indexPath string, recursive bool) error {
indexPath = indexPath + "/"
}
realPath := filepath.Join(idx.Path, indexPath)
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
// Check if directory exists
dirInfo, err := os.Stat(realPath)
+29
View File
@@ -105,6 +105,35 @@ func (info *FileInfo) SortItems() {
})
}
// PathWithinRoot reports whether path is equal to or under root after resolving symlinks.
func PathWithinRoot(root, path string) error {
rootAbs, err := filepath.Abs(root)
if err != nil {
return fmt.Errorf("invalid root path: %w", err)
}
rootReal, err := filepath.EvalSymlinks(rootAbs)
if err != nil {
return fmt.Errorf("could not resolve root path: %w", err)
}
pathAbs, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
pathReal, err := filepath.EvalSymlinks(pathAbs)
if err != nil {
return fmt.Errorf("could not resolve path: %w", err)
}
rel, err := filepath.Rel(rootReal, pathReal)
if err != nil {
return fmt.Errorf("path outside root: %w", err)
}
sep := string(filepath.Separator)
if rel == ".." || strings.HasPrefix(rel, ".."+sep) {
return fmt.Errorf("path escapes root")
}
return nil
}
// ResolveSymlinks resolves symlinks in the given path and returns
// Uses Go's filepath.EvalSymlinks which properly detects circular symlinks.
func ResolveSymlinks(path string) (string, bool, error) {
@@ -0,0 +1,178 @@
package indexing
import (
"errors"
"os"
"path/filepath"
"testing"
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupSymlinkEscapeFixture(t *testing.T) (sourceRoot, userScope string) {
t.Helper()
root := t.TempDir()
sourceRoot = filepath.Join(root, "srv")
userDir := filepath.Join(sourceRoot, "user1")
if err := os.MkdirAll(userDir, 0o755); err != nil {
t.Fatal(err)
}
outsideSecret := filepath.Join(root, "secret-outside.txt")
if err := os.WriteFile(outsideSecret, []byte("ROOT_DB_PASSWORD=supersecret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideSecret, filepath.Join(userDir, "secret_link")); err != nil {
t.Fatal(err)
}
sibling := filepath.Join(root, "outside-sibling.txt")
if err := os.WriteFile(sibling, []byte("outside sibling"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(sibling, filepath.Join(userDir, "sibling_link")); err != nil {
t.Fatal(err)
}
inside := filepath.Join(userDir, "allowed.txt")
if err := os.WriteFile(inside, []byte("allowed"), 0o644); err != nil {
t.Fatal(err)
}
return sourceRoot, "/user1"
}
func symlinkTestIndex(sourceRoot string) *Index {
return &Index{Source: settings.Source{Path: sourceRoot}}
}
func TestPathWithinRoot(t *testing.T) {
root := t.TempDir()
inside := filepath.Join(root, "inner", "file.txt")
if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(inside, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
outside := filepath.Join(filepath.Dir(root), "outside.txt")
if err := os.WriteFile(outside, []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
if err := iteminfo.PathWithinRoot(root, inside); err != nil {
t.Fatalf("expected inside path to be allowed: %v", err)
}
if err := iteminfo.PathWithinRoot(root, outside); err == nil {
t.Fatal("expected outside path to be rejected")
}
}
func TestGetRealPath_RejectsEscapeOutsideSource(t *testing.T) {
sourceRoot, _ := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
_, _, err := idx.GetRealPath("/user1/secret_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
}
_, _, err = idx.GetRealPath("/user1/sibling_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope for sibling link, got %v", err)
}
}
func TestGetRealPathScoped_RejectsEscapeOutsideUserScope(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
// Under source but outside /user1: place secret inside source root
insideSource := filepath.Join(sourceRoot, "secret-in-source.txt")
if err := os.WriteFile(insideSource, []byte("in source"), 0o644); err != nil {
t.Fatal(err)
}
userDir := filepath.Join(sourceRoot, "user1")
if err := os.Symlink(insideSource, filepath.Join(userDir, "in_source_link")); err != nil {
t.Fatal(err)
}
_, _, err := idx.GetRealPathScoped(userScope, "/user1/in_source_link")
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope for in-source escape, got %v", err)
}
}
func TestGetRealPathScoped_AllowsInScopeTarget(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
userDir := filepath.Join(sourceRoot, "user1")
if err := os.Symlink("allowed.txt", filepath.Join(userDir, "good_link")); err != nil {
t.Fatal(err)
}
realPath, _, err := idx.GetRealPathScoped(userScope, "/user1/good_link")
if err != nil {
t.Fatalf("expected in-scope symlink to resolve: %v", err)
}
want := filepath.Join(userDir, "allowed.txt")
realPath, err = filepath.EvalSymlinks(realPath)
if err != nil {
t.Fatal(err)
}
want, err = filepath.EvalSymlinks(want)
if err != nil {
t.Fatal(err)
}
if realPath != want {
t.Fatalf("got real path %q, want %q", realPath, want)
}
}
func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
insideSource := filepath.Join(sourceRoot, "secret-in-source.txt")
if err := os.WriteFile(insideSource, []byte("in source"), 0o644); err != nil {
t.Fatal(err)
}
userDir := filepath.Join(sourceRoot, "user1")
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("legitimate"), 0o644); err != nil {
t.Fatal(err)
}
// Legacy scoped cache keys used joinedPath + ":bound:" + bound, which could collide
// with an unscoped joinedPath when ":bound:" appeared in a path component.
legacyKey := filepath.Join(sourceRoot, "user1", "foo") + ":bound:" + "/user1"
RealPathCache.Set(legacyKey, insideSource)
IsDirCache.Set(legacyKey+":isdir", false)
realPath, _, err := idx.GetRealPathScoped(userScope, "/user1/foo")
if err != nil {
t.Fatalf("scoped in-scope file should resolve: %v", err)
}
want := filepath.Join(userDir, "foo")
want, err = filepath.EvalSymlinks(want)
if err != nil {
t.Fatal(err)
}
realPath, err = filepath.EvalSymlinks(realPath)
if err != nil {
t.Fatal(err)
}
if realPath != want {
t.Fatalf("scoped lookup returned %q, want in-scope file %q", realPath, want)
}
}
func TestGetFileInfo_RejectsSymlinkEscapeWithBound(t *testing.T) {
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
idx := symlinkTestIndex(sourceRoot)
_, err := idx.GetFileInfo(FileInfoRequest{
IndexPath: "/user1/secret_link",
FollowSymlinks: true,
BoundIndexPath: userScope,
})
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
t.Fatalf("expected ErrPathEscapesScope from GetFileInfo, got %v", err)
}
}
+2 -2
View File
@@ -4,10 +4,10 @@ package indexing
import (
"os"
"path/filepath"
"strings"
"syscall"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -66,7 +66,7 @@ func (idx *Index) handleFile(file os.FileInfo, indexPath string, realFilePath st
// Use provided realFilePath if available, otherwise construct it
if realFilePath == "" {
realFilePath = filepath.Join(idx.Path, indexPath)
realFilePath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
}
sys := file.Sys()
+6 -6
View File
@@ -149,6 +149,10 @@ func ValidateLdapAuth() error {
if ldapCfg.UserFilter == "" {
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
}
if ldapCfg.GroupsClaim == "" {
ldapCfg.GroupsClaim = defaultLdapGroupsClaim
}
applyAuthCommonDefaults(&ldapCfg.AuthCommon)
if err := verifyLdapConnection(); err != nil {
logger.Fatalf("LDAP connection check failed: %v", err)
}
@@ -158,9 +162,7 @@ func ValidateLdapAuth() error {
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
func validateOidcAuth() error {
oidcCfg := &Config.Auth.Methods.OidcAuth // Use a pointer to modify the original config
if oidcCfg.GroupsClaim == "" {
oidcCfg.GroupsClaim = "groups"
}
applyAuthCommonDefaults(&oidcCfg.AuthCommon)
if oidcCfg.UserIdentifier == "" {
oidcCfg.UserIdentifier = "preferred_username"
}
@@ -289,9 +291,7 @@ func ValidateJwtAuth() error {
if jwtCfg.Algorithm == "" {
jwtCfg.Algorithm = "HS256"
}
if jwtCfg.GroupsClaim == "" {
jwtCfg.GroupsClaim = "groups"
}
applyAuthCommonDefaults(&jwtCfg.AuthCommon)
if jwtCfg.UserIdentifier == "" {
jwtCfg.UserIdentifier = "sub"
}
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more