Compare commits

..
117 Commits
Author SHA1 Message Date
Graham Steffaniak 0f71239e14 Update readme v2.0.0 (#2745) 2026-08-07 15:04:49 -05:00
Graham Steffaniak 8fd7da09a5 added v1.5.5 to changelog 2026-08-07 13:55:42 -05:00
Graham Steffaniak 4b6611afaa Merge branch 'dev/v2.0.0' of github.com:gtsteffaniak/filebrowser into dev/v2.0.0 2026-08-07 13:55:20 -05:00
Graham SteffaniakandCursor 3f484f77d0 Better state activity names (#2743)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 13:51:13 -05:00
Graham Steffaniak 9275b34622 Merge branch 'dev/v2.0.0' of github.com:gtsteffaniak/filebrowser into dev/v2.0.0 2026-08-07 11:34:51 -05:00
Graham SteffaniakandCursor 94481b83b6 Prerelease false beta (#2741)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:32:07 -05:00
Graham SteffaniakandCursor d61720817e Merge remote-tracking branch 'origin/dev/v2.0.0' into dev/v2.0.0
Accept #2739 minimal token fix from origin; resolves duplicate local commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 10:50:52 -05:00
Graham Steffaniak d30fbb1afd fix minimal token creation bug in v2.0.0 (#2739) 2026-08-07 10:49:31 -05:00
Graham Steffaniak 1a7a94d301 fix minimal token creation bug 2026-08-07 10:27:20 -05:00
Graham Steffaniak d809654e3c use ubuntu latest for docker release 2026-08-07 09:47:41 -05:00
Graham Steffaniak 14350c31a5 remove trusted header warning on devmode (#2732) 2026-08-06 20:19:52 -05:00
Graham Steffaniak 3d06ee685a Merge branch 'dev/v2.0.0' of github.com:gtsteffaniak/filebrowser into dev/v2.0.0 2026-08-06 18:55:43 -05:00
Graham Steffaniak 7feb44caf9 Use arm64 runners (#2731) 2026-08-06 18:53:30 -05:00
Graham Steffaniak 53ed0095e1 updated with share upload fix (#2730) 2026-08-06 18:49:30 -05:00
Graham Steffaniak 2d49cb4508 updated with share upload fix 2026-08-06 16:36:32 -05:00
Graham Steffaniak 458c25a6c5 added source default permission behavior like userDefaults (#2729) 2026-08-06 16:22:45 -05:00
Graham Steffaniak 2910415458 changed activity viewer table styling (#2728) 2026-08-06 16:13:04 -05:00
Graham Steffaniak 795b12ee2a removed which=all and improved activity logs (#2726) 2026-08-06 11:43:44 -05:00
Graham Steffaniak 5b5b10cde6 Merge branch 'dev/v2.0.0' of github.com:gtsteffaniak/filebrowser into dev/v2.0.0 2026-08-05 19:11:26 -05:00
Graham Steffaniak bb7b6447ae updated onlyoffice view grant (#2723) 2026-08-05 18:25:17 -05:00
Graham Steffaniak 16d1b628a7 updated onlyoffice view grant 2026-08-05 18:11:15 -05:00
Graham Steffaniak 330f9c8db8 View grant plyr fixes (#2710) 2026-08-05 16:45:37 -05:00
Graham Steffaniak c34c8d0a0a fix v2.0.0 totp backend user struct check (#2721) 2026-08-05 13:56:24 -05:00
Graham Steffaniak f314695267 fix windows slash issue in v2.0.0 (#2720) 2026-08-05 11:15:27 -05:00
Graham Steffaniak d4cd8c3300 Trusted headers replace with boolean (#2719) 2026-08-05 10:35:31 -05:00
Graham Steffaniak e04bfc3182 upgraded ffmpeg to 9.0 and merged changelog (#2718) 2026-08-05 09:35:59 -05:00
Graham Steffaniak 02ec827c72 trusted headers check changes (#2716) 2026-08-04 14:36:21 -05:00
Graham Steffaniak 0953ce40dd log specific value change for user diff (#2711) 2026-08-03 22:08:28 -05:00
Kevin B. 72b9d6d14e fix dynamic imports (#2712) 2026-08-03 16:33:06 -05:00
Graham Steffaniak 9a941507f4 removed default media player option + cleanup (#2709) 2026-08-03 10:59:24 -05:00
Graham Steffaniak b0d1af05df port v1.5.4 changes to v2.0.0 (#2703) 2026-08-02 11:36:52 -05:00
Graham Steffaniak 8b26cfd299 enhancements to file permissions functionality (#2697) 2026-08-02 11:33:31 -05:00
Graham Steffaniak 7c05cd66ea added remaining comments and functionality changes (#2694) 2026-08-01 16:29:47 -05:00
Graham Steffaniak 3483975b90 add view token fix (#2691) 2026-07-31 18:30:09 -05:00
Graham Steffaniak f76c11333b updated with small changes (#2693) 2026-07-31 18:27:13 -05:00
Graham Steffaniak 78c56dac8d fix single file stream issue (#2692) 2026-07-31 17:46:24 -05:00
Graham Steffaniak 8ac2f1b3af fix x forwarded headers bug (#2690) 2026-07-31 16:27:22 -05:00
Graham Steffaniak 12da90a7a7 updated with migration fix for noauth (#2689) 2026-07-31 16:18:48 -05:00
Graham Steffaniak d14267994a add default migration path behavior (#2688) 2026-07-31 15:51:47 -05:00
Kevin B. ccafe483d8 re-add swipe to dismiss notifications (#2672) 2026-07-29 18:21:04 -05:00
Graham Steffaniak 818d432cc8 fix windows build (#2670) 2026-07-24 10:33:57 -05:00
Graham Steffaniak f1ae627854 Fix 32bit build (#2668) 2026-07-23 20:23:28 -05:00
Graham Steffaniak b4d23ab1f6 Small fixes (#2666) 2026-07-23 18:39:27 -05:00
Graham Steffaniak 81e67cfcee updated with small changes before preview (#2665) 2026-07-23 18:37:35 -05:00
Graham Steffaniak eedc6f0f92 port v1.5.x bugfix changes (#2663) 2026-07-23 15:40:42 -05:00
Graham Steffaniak 27854cf738 addressed user issue and dropdown (#2662) 2026-07-23 14:09:30 -05:00
Graham Steffaniak e753dd5966 updated with kong cli (#2660) 2026-07-23 11:53:32 -05:00
Graham Steffaniak c630c002dc http config migration (#2653) 2026-07-22 19:28:22 -05:00
Graham Steffaniak d141c8fa7d updated migration behavior and tests (#2656) 2026-07-22 19:11:32 -05:00
Kevin B. 5c3e7e6560 fix profile settings update and vite warning (#2655) 2026-07-21 19:51:23 -05:00
Graham Steffaniak 304bef7909 Move user defaults (#2651) 2026-07-21 13:05:57 -05:00
Graham Steffaniak 050e89e44a Cleanup v2.0.0 (#2648) 2026-07-20 12:48:50 -05:00
Kevin B. 8dbc69fa73 fix plyr popup position, placeholder and restore fullscreen and pip (#2649) 2026-07-20 11:54:08 -05:00
Kevin B. b52c25582b preserve dir modtime with copy (#2647) 2026-07-20 10:01:30 -05:00
Graham Steffaniak edcd580f0e feature: per-source permissions (#2640) 2026-07-17 18:37:22 -05:00
Kevin B. 2a1cc76ece preserve files mod time with copy operations (#2642) 2026-07-17 16:59:04 -05:00
Graham Steffaniakandcoderabbitai[bot] 30fa7279e6 updated with analytics (#2637)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-16 11:04:33 -05:00
Kevin B. c7cfe66797 add bulgarian translation and deprecate iscelandic (#2634) 2026-07-15 19:55:12 -05:00
Kevin B. 4d76a8fdb5 add webdav X-OC-Mtime header support (#2626) 2026-07-15 13:50:25 -05:00
Graham SteffaniakandGraham 9980dd4d22 removed exiftool from requirements (#2622)
Co-authored-by: Graham <info@quantumx-apps.com>
2026-07-11 12:22:51 -05:00
dd169dccbd Lazy load some components to help initial page load (#2615)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: Graham <info@quantumx-apps.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-07-11 10:52:53 -05:00
Kevin B. ddb72de88c Add visualizer config UI (#2620) 2026-07-11 10:21:56 -05:00
Kevin B. d607c61e56 fix plyr scrub timestamp and improve the styles a bit (#2618) 2026-07-09 18:08:24 -05:00
Graham Steffaniak 24534161b9 Restructure backend standards (#2601) 2026-07-05 07:54:30 -05:00
Kevin B. 86bac02f66 Improve more the playback queue + metadata caching (#2600) 2026-07-05 07:47:13 -05:00
Graham SteffaniakandGraham 35ae1b3869 updated with preview thumbnails (#2597)
Co-authored-by: Graham <info@quantumx-apps.com>
2026-07-04 08:50:41 -05:00
Graham Steffaniak a6a3fd1268 updated with some api consolidation and changes (#2596) 2026-07-03 08:46:06 -05:00
Graham Steffaniak a58dc776c3 updated with changes removing legacy support (#2594) 2026-07-02 18:38:03 -05:00
Kevin B. 0324f37d91 fix dropdown position if not enough space (#2587) 2026-07-02 15:46:53 -05:00
Kevin B. c6625657a6 Add metadata for playback queue (#2585) 2026-06-28 13:16:30 -05:00
Kevin B. 31831a74e5 Add audio visualizer, more gestures, and improve things in media player (#2575) 2026-06-26 16:01:23 -05:00
Graham Steffaniak 7684804d94 migrate to new go-ffmpeg library (#2579) 2026-06-25 13:39:13 -05:00
Graham Steffaniak e6039e18f3 updated linting 2026-06-24 19:44:58 -05:00
Graham Steffaniak 6a11094c05 adding downloads (#2577) 2026-06-24 18:31:26 -05:00
Graham Steffaniak 051d6672ae updated styles (#2572) 2026-06-24 15:58:54 -05:00
Graham Steffaniak 8e716fa2c1 updated with activity enhancements (#2571) 2026-06-23 16:56:20 -05:00
Graham SteffaniakandGraham 833e180fad updated with activity viewer (#2562)
Co-authored-by: Graham <info@quantumx-apps.com>
2026-06-22 13:33:38 -05:00
Graham SteffaniakandGraham c38db32ee7 merged changes (#2557)
Co-authored-by: Graham <info@quantumx-apps.com>
2026-06-19 13:02:51 -05:00
+5 b08c6c2389 Merge v1.5.0 v2.0.0 (#2545)
Co-authored-by: Taufik Hidayat <taufikdev88@gmail.com>
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: 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>
2026-06-13 14:59:14 -05:00
Graham Steffaniak f743ff51d0 make paths more safe (#2475) 2026-05-21 15:20:47 -05:00
Graham Steffaniak e8f25a1673 Update share properties (#2474) 2026-05-20 18:03:32 -05:00
Graham Steffaniak 59cd9ef7f0 updated more share properties (#2473) 2026-05-20 18:01:56 -05:00
4e6f044407 Merge v1.4.0 2.0.0 (#2423)
Co-authored-by: Taufik Hidayat <taufikdev88@gmail.com>
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: 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>
2026-05-14 16:19:56 -05:00
3167aba55c Merge v2.0.0 v1.3.9 (#2380)
Co-authored-by: Taufik Hidayat <taufikdev88@gmail.com>
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: Gaurav Kalal <117557391+Gorupa@users.noreply.github.com>
Co-authored-by: Nebula <40148908+nebula-it@users.noreply.github.com>
2026-05-04 12:10:32 -05:00
Graham Steffaniak 96c11c7373 merge v1.3.4 into v2.0.0 (#2284) 2026-04-13 18:32:57 -05:00
682b9257f5 v2.0.0 User share protections (#2258)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: carloda <35763669+carloda@users.noreply.github.com>
2026-04-13 18:07:45 -05:00
Graham Steffaniak e26e904962 updated user and share formatting 2026-04-07 19:35:27 -05:00
Graham Steffaniak b326c22854 Merge branch 'dev/v2.0.0' of github.com:gtsteffaniak/filebrowser into dev/v2.0.0 2026-04-07 17:48:24 -05:00
c07ddc4157 Refactor user (#2254)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: carloda <35763669+carloda@users.noreply.github.com>
2026-04-07 17:48:12 -05:00
Graham Steffaniak 949e2687fc Merge branch 'dev/v2.0.0' of github.com:gtsteffaniak/filebrowser into dev/v2.0.0 2026-04-06 18:07:04 -05:00
fa5d5d5f8e V2.0.0 merge v1.3.3 (#2249)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: carloda <35763669+carloda@users.noreply.github.com>
2026-04-06 17:27:08 -05:00
Graham Steffaniak 3b529777a8 Merge branch 'main' into dev/v2.0.0 2026-04-06 15:41:15 -05:00
Graham Steffaniak 88e37c0667 Merge branch 'dev/v1.3.3' into dev/v2.0.0 2026-03-23 16:26:11 -05:00
Graham Steffaniak afd84ea35a Merge branch 'dev/v1.3.2' into dev/v2.0.0 2026-03-20 10:00:56 -05:00
Graham Steffaniak cdbc7a35f7 Merge branch 'dev/v1.3.2' of github.com:gtsteffaniak/filebrowser into dev/v1.3.2 2026-03-20 10:00:14 -05:00
Graham Steffaniak a18f268547 updated with maxArchiveSize settings (#2155) 2026-03-19 19:24:35 -05:00
Graham Steffaniak 28edf9ce0e updated with maxArchiveSize settings 2026-03-19 18:42:33 -05:00
Graham Steffaniak e99b22349d Fix put api folders (#2153) 2026-03-19 18:19:00 -05:00
Graham Steffaniak 9775cc03fd updated logging and concurrency (#2152) 2026-03-19 12:58:39 -05:00
Graham Steffaniak 18333e21fd updated rule inheritance (#2151) 2026-03-18 20:07:43 -05:00
Shane Chiu ad3d184811 fix: Prevent root directory deletion and fix bulk delete path bug with added tests (#2146) 2026-03-18 20:06:02 -05:00
Graham Steffaniak d47c2d9c0d pwa changes for public icons (#2150) 2026-03-18 16:46:17 -05:00
Graham Steffaniak 91b67a395e Merge branch 'dev/v1.3.2' into dev/v2.0.0 2026-03-18 15:21:31 -05:00
Graham Steffaniak 04e4324981 Merge branch 'main' into dev/v1.3.2 2026-03-18 15:21:10 -05:00
Graham Steffaniak c1c0f7a25b Merge branch 'dev/v2.0.0' of github.com:gtsteffaniak/filebrowser into dev/v2.0.0 2026-03-18 15:20:43 -05:00
df9e7571a1 Merge v1.3.2 v2.0.0 (#2149)
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>
2026-03-18 15:12:23 -05:00
Graham Steffaniak 94d6045a22 Merge branch 'dev/v1.3.2' into dev/v2.0.0 2026-03-18 14:30:27 -05:00
Graham Steffaniak 44edfcafa1 Merge branch 'main' into dev/v1.3.2 2026-03-18 14:28:52 -05:00
Kevin B. be2e78f729 Fix delete and fileTree navigation issues (#2142) 2026-03-18 14:27:43 -05:00
Graham Steffaniak c22fc59035 fix user scope, download, and edit issue for onlyoffice (#2145) 2026-03-18 14:27:02 -05:00
Graham Steffaniak 29b631657f Merge branch 'main' into dev/v2.0.0 2026-03-17 12:59:35 -05:00
Graham Steffaniak ad82de28e8 Merge branch 'main' into dev/v1.3.2 2026-03-17 12:58:40 -05:00
Graham Steffaniak fea07ef202 Merge branch 'main' into dev/v2.0.0 2026-03-16 17:48:14 -05:00
Graham Steffaniak 0481052308 Merge branch 'main' into dev/v1.3.2 2026-03-16 17:47:54 -05:00
Graham Steffaniak 2f024c3fee Migrate backend to sqlite -- first phase (#2123) 2026-03-12 14:05:15 -05:00
Graham Steffaniak 87d241d166 added preview for pdf timeout (#2114) 2026-03-09 19:08:37 -05:00
092c97f9ae Rename prompts functions (#2108)
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>
2026-03-09 18:54:17 -05:00
144 changed files with 2001 additions and 6709 deletions

No files matched your search

-44
View File
@@ -5,9 +5,6 @@ on:
branches:
- "main"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright
@@ -33,44 +30,3 @@ jobs:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_beta_release:
needs: [test_playwright]
if: startsWith(github.event.head_commit.message, 'Beta/v')
name: Create Beta Release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
fetch-depth: 0
- name: Extract version from commit message
id: extract_version
shell: bash
run: |
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
exit 1
fi
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
- name: Tag release commit on main
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
TAG="${{ steps.extract_version.outputs.tag_name }}"
git tag -f "$TAG" "${{ github.sha }}"
git push origin "refs/tags/$TAG" --force
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_version.outputs.tag_name }}
prerelease: false
make_latest: false
draft: false
generate_release_notes: true
overwrite_existing: true
name: ${{ steps.extract_version.outputs.tag_name }}
+5 -2
View File
@@ -22,8 +22,11 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- working-directory: backend
run: go tool golangci-lint run --path-prefix=backend
- uses: golangci/golangci-lint-action@v7
with:
version: 'v2.1.6'
working-directory: backend
install-mode: goinstall
format-backend:
runs-on: ubuntu-24.04-arm
steps:
+66
View File
@@ -0,0 +1,66 @@
name: beta release
on:
push:
branches:
- "beta/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
test_playwright:
name: Test Playwright - regular
runs-on: ubuntu-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 }}
-44
View File
@@ -2,50 +2,6 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v2.0.2
**Bugfixes**:
- OIDC: `groupsClaim` value is always included in requested scopes and falls back to the UserInfo endpoint when the ID token omits the groups claim.
- Windows: fix backslash duplication in navigation URLs, folder sizes showing 4 KB, and download/preview failures (#2815, #2816)
- Wrong extension in the 'new database was created popup (#2817) (#2824)
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
- OnlyOffice is inaccessible on password-protected shares (#2811)
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
## v2.0.1
**Security**:
- [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds
- [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo
- [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu)
- [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec
- [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209
**Notes**:
- GroupMap `SyncUserGroups` fix so JWT/OIDC/LDAP group memberships survive restart (#2742).
- Session renew is handled by client keep-alive. removed per-request `X-Renew-Token` header handling.
- Improvements to document thumbnail generation performance.
- behavior changes for typing to select files in listing view.
- next/previous buttons don't hide automatically on photos (#2767)
- added more actions available in advanced search (#2776)
**Bugfixes**:
- GroupMap mutations (`SyncUserGroups`, add/remove group members) are write-through to SQL so JWT/OIDC/LDAP group memberships survive restart (#2742).
- `defaultEnabled` behavior change/fix so defaultEnabled means its always added to users on startup and login. (updated docs too)
- Long uploads/downloads no longer lose the session mid-transfer: proactive session JWT keep-alive renews before expiry (#2638).
- Disable auto logout/session expiry if there are active file transfers going (#2638)
- upload options get cut off on mobile (#2685)
- Uploading image: image gets cut-off (#2765)
- Process aborts (not just 500) during PDF thumbnail generation (#2763)
- PDF preview generation prevents file uploads from completing (#2752)
- Brings back double-tap to zoom images that was mistakenly removed.
- multiple embedded subtitles with same language bug (#2756)
- gallery view download button missing (#2767)
- fixed HEIC rotation regression from v1.5.x
- fixed raw image preview regression from v1.5.x
- html viewer takes full height
- Members without download permission receive 403 when opening text-based files despite OnlyOffice preview being enabled (#2777)
## v2.0.0
This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary, and the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert legacy config.
-1
View File
@@ -17,7 +17,6 @@
## 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,7 +5,6 @@ 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,7 +2,6 @@ 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"
+1 -2
View File
@@ -2,8 +2,7 @@ root = "."
tmp_dir = "tmp"
[build]
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
cmd = "go build -o ./tmp/filebrowser.exe --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
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'\" ."
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"]
+2 -8
View File
@@ -2,11 +2,6 @@
project_name: filebrowser
version: 2
before:
hooks:
- go mod download
- go run ./scripts/setup-gofitz-cgo
builds:
# Build configuration for linux amd64
- id: linux-amd64
@@ -71,8 +66,7 @@ builds:
- id: windows
env:
- CGO_ENABLED=1
# windows-gnu avoids zig rejecting Go's -tsaware when cross-compiling from macOS
- CC=zig cc -target x86_64-windows-gnu -lc
- CC=zig cc -target x86_64-windows
tags:
- analytics
- mupdf
@@ -99,7 +93,7 @@ builds:
goarch:
- amd64
# Build configuration for macos without upx (no mupdf: CGO cross-compile from Linux CI is unsupported)
# Build configuration for macos without upx
- id: macos
ldflags: *ldflags
main: main.go
+8 -28
View File
@@ -87,7 +87,7 @@ func TestAdminHasSharePermissionAfterSettingsMigration(t *testing.T) {
assertSourceSidebarLinkCount(t, &admin, 3)
}
func TestGrahamGainsAccessAfterSettingsMigrationStartup_dockerSourcePaths(t *testing.T) {
func TestGrahamNoAccessAfterSettingsMigrationStartup_dockerSourcePaths(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
configPath := filepath.Join(t.TempDir(), "config.yaml")
configYAML := `server:
@@ -136,29 +136,19 @@ auth:
if err != nil {
t.Fatal(err)
}
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
foundScope = true
break
t.Fatalf("graham gained access scope: %#v", graham.BackendScopes)
}
}
if !foundScope {
t.Fatalf("graham should gain access scope after validateUserInfo: %#v", graham.BackendScopes)
}
foundLink := false
for _, link := range graham.SidebarLinks {
if link.Name == "access" || link.SourceName == fixtureAccessSource {
foundLink = true
break
if link.Name == "access" {
t.Fatalf("graham has access sidebar link: %#v", graham.SidebarLinks)
}
}
if !foundLink {
t.Fatalf("graham should have access sidebar link after validateUserInfo: %#v", graham.SidebarLinks)
}
}
func TestGrahamGainsAccessAfterSettingsMigrationStartup(t *testing.T) {
func TestGrahamNoAccessAfterSettingsMigrationStartup(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
@@ -184,26 +174,16 @@ func TestGrahamGainsAccessAfterSettingsMigrationStartup(t *testing.T) {
if err != nil {
t.Fatal(err)
}
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
foundScope = true
break
t.Fatalf("graham gained access scope after validateUserInfo: %#v", graham.BackendScopes)
}
}
if !foundScope {
t.Fatalf("graham should gain access scope after validateUserInfo: %#v", graham.BackendScopes)
}
foundLink := false
for _, link := range graham.SidebarLinks {
if link.Name == "access" || link.SourceName == fixtureAccessSource {
foundLink = true
break
if link.Name == "access" {
t.Fatalf("graham has access sidebar link after validateUserInfo: %#v", graham.SidebarLinks)
}
}
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")
}
+3 -6
View File
@@ -238,14 +238,11 @@ func createConfig(configpath string, noInput bool) error {
}
for {
// "database.db" is the reserved legacy-bolt filename and is rejected
// at startup (see backend/cmd/migrate.go), so the wizard must not
// suggest it as the default.
config.Server.DatabaseV2.Path = askQuestion(reader, "What should the file name and path be for the database?", "./filebrowser.sqlite")
if strings.HasSuffix(config.Server.DatabaseV2.Path, ".sqlite") {
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") {
break
}
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .db", config.Server.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")
+43 -1
View File
@@ -127,9 +127,51 @@ func dedupeFields(fields []string) []string {
}
func updateUserScopes(user *users.User) bool {
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
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)
}
}
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
user.BackendScopes = newScopes
return changed
}
+20 -37
View File
@@ -10,7 +10,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestUpdateUserScopes_mergesMissingDefaultEnabled(t *testing.T) {
func TestUpdateUserScopes_preservesPartialScopes(t *testing.T) {
settings.Initialize(settingsMigrationConfigPath(t))
alignSettingsSourcesForMigrationFixture(t)
@@ -22,31 +22,18 @@ func TestUpdateUserScopes_mergesMissingDefaultEnabled(t *testing.T) {
},
}
if !updateUserScopes(user) {
t.Fatal("expected missing defaultEnabled access source to be merged")
updateUserScopes(user)
if len(user.BackendScopes) != 2 {
t.Fatalf("scopes=%#v want 2 partial scopes", user.BackendScopes)
}
if len(user.BackendScopes) != 3 {
t.Fatalf("scopes=%#v want 3 including access", user.BackendScopes)
}
found := false
for _, scope := range user.BackendScopes {
if scope.Path == fixtureAccessSource {
found = true
break
}
}
if !found {
t.Fatalf("access source should be added for defaultEnabled: %#v", user.BackendScopes)
}
// Preserve existing scope paths
for _, scope := range user.BackendScopes {
if scope.Path == fixturePlaywrightSource && scope.Scope != "/myfolder" {
t.Fatalf("playwright scope path changed: %#v", scope)
t.Fatalf("access source should not be added to partial-scope user: %#v", user.BackendScopes)
}
}
}
func TestMigrationGraham_gainsAccessAfterScopeMerge(t *testing.T) {
func TestMigrationGraham_noAccessScopeOrSidebarLink(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize(settingsMigrationConfigPath(t))
settings.Env.IsPlaywright = true
@@ -72,39 +59,35 @@ func TestMigrationGraham_gainsAccessAfterScopeMerge(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 from migration alone: %#v", graham.BackendSourcePermissions)
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)
}
}
updateUserScopes(graham)
updateSourcePermissions(graham)
updateSidebarLinks(graham)
foundScope := false
for _, scope := range graham.BackendScopes {
if scope.Path == fixtureAccessSource {
foundScope = true
break
t.Fatalf("graham should not gain access scope: %#v", graham.BackendScopes)
}
}
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)
if _, ok := graham.BackendSourcePermissions[fixtureAccessSource]; ok {
t.Fatalf("graham should not have access source permissions: %#v", graham.BackendSourcePermissions)
}
foundLink := false
for _, link := range usersidebar.FrontendLinks(graham.SidebarLinks, true) {
if link.Name == "access" || link.SourceName == fixtureAccessSource {
foundLink = true
break
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)
}
}
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,12 +45,9 @@ 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"},
},
},
{
@@ -64,7 +61,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
{Scope: "/defaultB", Path: "/pathB"},
},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
@@ -99,7 +95,6 @@ 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"},
},
@@ -113,7 +108,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
{Scope: "/defaultB", Path: "/pathB"},
},
expectedPhase2: []users.BackendScope{
{Scope: "/defaultA", Path: "/pathA"},
{Scope: "/defaultB", Path: "/pathB"},
},
},
+10
View File
@@ -0,0 +1,10 @@
userDefaults:
preview:
image: true
video: true
audio: true
motionVideoPreview: true
office: true
popup: true
folder: true
models: true
+96 -91
View File
@@ -3,7 +3,7 @@ module github.com/gtsteffaniak/filebrowser/backend
go 1.26.0
require (
github.com/alecthomas/kong v1.16.1
github.com/alecthomas/kong v1.16.0
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,48 +19,47 @@ 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.2
github.com/gtsteffaniak/go-ffmpeg v0.5.1
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.50
github.com/mattn/go-sqlite3 v1.14.49
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.12.1
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
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/crypto v0.54.0
golang.org/x/image v0.44.0
golang.org/x/mod v0.38.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.49.0
golang.org/x/tools v0.48.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.57.0
modernc.org/sqlite v1.55.0
)
require (
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
codeberg.org/chavacava/garif v0.2.1 // indirect
charm.land/lipgloss/v2 v2.0.3 // indirect
codeberg.org/chavacava/garif v0.2.0 // 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/exhaustruct/v5 v5.0.3 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
dev.gaijin.team/go/golib v0.6.0 // indirect
github.com/4meepo/tagalign v1.4.3 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
github.com/Abirdcfly/dupword v0.1.7 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // 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/AlwxSin/noinlineerr v1.0.5 // indirect
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // 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.1 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.0 // 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
@@ -74,41 +73,43 @@ 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.2 // indirect
github.com/andybalholm/brotli v1.2.0 // 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.25.0 // indirect
github.com/bits-and-blooms/bitset v1.24.5 // 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.9.0 // indirect
github.com/bombsimon/wsl/v5 v5.8.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.3 // indirect
github.com/butuzov/mirror v1.3.0 // 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-20260812204455-68fa937c71be // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // 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.7 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.14.0 // indirect
github.com/dave/dst v0.27.4 // 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/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.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
@@ -116,26 +117,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.8 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // 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/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ghostiam/protogetter v0.3.21 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-critic/go-critic v0.14.4 // indirect
github.com/go-critic/go-critic v0.14.3 // 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.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-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-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
@@ -146,26 +147,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.3.0 // indirect
github.com/go-webauthn/x v0.2.6 // 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 v1.0.0 // indirect
github.com/gohugoio/hugo v0.165.0 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/gohugoio/hugo v0.164.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/geo v0.0.0-20260713102120-857a528af641 // indirect
github.com/golang/protobuf v1.5.3 // 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-20260820135601-e84e05053792 // indirect
github.com/golangci/golangci-lint/v2 v2.13.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/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-20260801150524-d2031e3af752 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // 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
@@ -177,14 +178,15 @@ 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.11.0 // indirect
github.com/jgautheron/goconst v1.10.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.20.0 // indirect
github.com/kisielk/errcheck v1.10.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
@@ -192,90 +194,93 @@ 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.9.0 // indirect
github.com/ldez/gomoddirectives v0.8.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.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // 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.15 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.28 // indirect
github.com/mgechev/revive v1.16.0 // 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/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.24.0 // indirect
github.com/nunnatsa/ginkgolinter v0.23.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/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/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/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.3.0 // indirect
github.com/raeperd/recvcheck v0.2.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.16.0 // indirect
github.com/rogpeppe/go-internal v1.15.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.3 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.28.0 // indirect
github.com/sirupsen/logrus v1.10.1 // indirect
github.com/securego/gosec/v2 v2.26.1 // indirect
github.com/sirupsen/logrus v1.9.4 // 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.21.0 // indirect
github.com/spf13/viper v1.12.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.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // 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.27.7 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.5.0 // indirect
github.com/uudashr/iface v1.4.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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
@@ -283,25 +288,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.10.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.etcd.io/bbolt v1.5.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.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // 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-20260820142414-ca536658362e // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/protobuf v1.36.12 // 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
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.8.1 // indirect
modernc.org/libc v1.75.4 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // 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
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
)
tool (
+693 -247
View File
File diff suppressed because it is too large. Load diff
+7 -23
View File
@@ -154,7 +154,7 @@ func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service)
return "", "", errors.ErrAccessDenied
}
indexPath := utils.JoinScopedIndexPath(userScope, safePath)
indexPath := utils.JoinPathAsUnix(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, userScope, topLevelErr := checkPermissionsImpl(opts, user, s)
indexPath, _, topLevelErr := checkPermissionsImpl(opts, user, s)
accessRulesErr := topLevelErr != nil && topLevelErr == errors.ErrAccessDenied && indexPath != ""
if topLevelErr != nil && !accessRulesErr {
return items, topLevelErr
@@ -200,18 +200,14 @@ 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)
}
infoReq := indexing.FileInfoRequest{
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
HideFileExt: opts.HideFileExt,
Expand: true,
SkipExtendedAttrs: true,
}
if opts.FollowSymlinks {
infoReq.BoundIndexPath = userScope
}
info, err := idx.GetFileInfo(infoReq)
})
if err != nil {
return items, err // Path excluded by index rules OR doesn't exist
}
@@ -265,18 +261,14 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*
}
// Layer 2: INDEX RULES (global)
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
fileInfoReq := indexing.FileInfoRequest{
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
HideFileExt: opts.HideFileExt,
Expand: opts.Expand,
SkipExtendedAttrs: opts.SkipExtendedAttrs,
}
if opts.FollowSymlinks {
fileInfoReq.BoundIndexPath = userScope
}
info, err := idx.GetFileInfo(fileInfoReq)
})
if err != nil {
return response, err // Path excluded by index rules OR doesn't exist
}
@@ -293,15 +285,7 @@ func fileInfoFasterImpl(opts utils.FileOptions, user *users.User, s *Service) (*
// Build response
response.FileInfo = *info
if opts.FollowSymlinks && info.Type != "directory" {
realPath, _, err := idx.GetRealPathScoped(userScope, indexPath)
if err != nil {
return response, err
}
response.RealPath = realPath
} else {
response.RealPath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
}
response.RealPath = filepath.Join(idx.Path, indexPath)
response.Source = opts.Source
if user.Permissions.Share && opts.ShowSharedAttr {
for i := range response.Files {
-124
View File
@@ -1,124 +0,0 @@
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)
}
}
+22 -54
View File
@@ -89,39 +89,11 @@ func (s *Storage) SetSQLStore(sqlStore SQLPersister) {
s.sqlStore = sqlStore
}
// Flush is a no-op; access rule and group updates already write through sqlStore when configured.
// Flush is a no-op; access rule 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{
@@ -263,11 +235,13 @@ 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()
s.ensureGroupExistsNL(groupname)
_, ok := s.Groups[groupname]
if !ok {
return fmt.Errorf("group '%s' does not exist", groupname)
}
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Groups[groupname]; ok {
return errors.ErrExist
@@ -279,11 +253,13 @@ 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()
s.ensureGroupExistsNL(groupname)
_, ok := s.Groups[groupname]
if !ok {
return fmt.Errorf("group '%s' does not exist", groupname)
}
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Groups[groupname]; ok {
return errors.ErrExist
@@ -540,8 +516,6 @@ func (s *Storage) AddUserToGroup(group, username string) error {
return nil
}
s.Groups[group][username] = struct{}{}
s.persistGroupSQLNL(group)
s.clearAllCaches()
return nil
}
@@ -572,30 +546,26 @@ 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()
affected := make(map[string]struct{})
changed := false
// 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, members := range s.Groups {
_, userIsInGroup := members[username]
for group, users := range s.Groups {
_, userIsInGroup := users[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)
affected[group] = struct{}{}
changed = true
}
}
@@ -606,14 +576,11 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
}
if _, ok := s.Groups[group][username]; !ok {
s.Groups[group][username] = struct{}{}
affected[group] = struct{}{}
changed = true
}
}
for group := range affected {
s.persistGroupSQLNL(group)
}
if len(affected) > 0 {
s.clearAllCaches()
if changed {
return nil
}
return nil
}
@@ -622,16 +589,17 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
func (s *Storage) RemoveUserFromGroup(group, username string) error {
s.mux.Lock()
defer s.mux.Unlock()
members, ok := s.Groups[group]
users, ok := s.Groups[group]
if !ok {
return nil
}
if _, ok := members[username]; !ok {
if _, ok := users[username]; !ok {
return nil
}
delete(members, username)
s.persistGroupSQLNL(group)
s.clearAllCaches()
delete(users, username)
if len(s.Groups[group]) == 0 {
delete(s.Groups, group)
}
return nil
}
+7 -146
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,13 +62,7 @@ func (s *sqlStoreAdapter) DeleteByID(id uint64) error {
}
func createTestStorage(t *testing.T) (*access.Storage, *users.Storage) {
t.Helper()
accessStore, userStore, _ := createTestStorageWithSQL(t)
return accessStore, userStore
}
func createTestStorageWithSQL(t *testing.T) (*access.Storage, *users.Storage, *sqldb.SQLStore) {
t.Helper()
// Create a temporary directory for the test database
tempDir, err := os.MkdirTemp("", "access_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -77,6 +71,7 @@ func createTestStorageWithSQL(t *testing.T) (*access.Storage, *users.Storage, *s
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 {
@@ -86,11 +81,10 @@ func createTestStorageWithSQL(t *testing.T) (*access.Storage, *users.Storage, *s
sqlStore.Close()
})
// Wrap SQLStore with adapter
adapter := &sqlStoreAdapter{store: sqlStore}
userStore := users.NewStorage(adapter)
accessStore := access.NewStorage(userStore)
accessStore.SetSQLStore(sqlStore)
return accessStore, userStore, sqlStore
return access.NewStorage(userStore), userStore
}
func createTestUser(t *testing.T, userStore *users.Storage, username string) {
@@ -215,27 +209,6 @@ 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)
@@ -1739,115 +1712,3 @@ 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,11 +310,6 @@ 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
-1
View File
@@ -23,7 +23,6 @@ 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")
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
ErrUseMediaStream = errors.New("use /media/stream for audio and video")
+17 -37
View File
@@ -2,9 +2,7 @@ package ffmpeg
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
@@ -37,42 +35,24 @@ 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 {
subtitles = append(subtitles, mapper.track(stream))
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)
}
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)
}
+7 -100
View File
@@ -6,38 +6,6 @@ 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"},
@@ -49,72 +17,11 @@ 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].Name != "Track 0" {
t.Fatalf("tracks[0].Name = %q, want Track 0", 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[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)
}
if tracks[1].Index == nil || *tracks[1].Index != 1 {
t.Fatalf("tracks[1].Index = %v, want 1", tracks[1].Index)
}
}
@@ -127,17 +34,17 @@ func TestMapSubtitleTracks_nameFormats(t *testing.T) {
{
name: "title only",
stream: goffmpeg.SubtitleTrack{Index: 3, Title: "Director's Commentary"},
wantName: "Track 3",
wantName: "Embedded Subtitle 3 (Director's Commentary)",
},
{
name: "language only",
stream: goffmpeg.SubtitleTrack{Index: 5, Language: "deu"},
wantName: "Track 5",
wantName: "Embedded Subtitle 5 (deu)",
},
{
name: "no title or language",
stream: goffmpeg.SubtitleTrack{Index: 7},
wantName: "Track 7",
wantName: "Embedded Subtitle 7",
},
}
+12 -24
View File
@@ -81,11 +81,19 @@ func extractTIFFEmbeddedPreview(f *os.File) ([]byte, error) {
return nil, err
}
candidates := previewCandidates(ex)
if scanCandidates, scanErr := collectTIFFPreviewCandidates(f); scanErr == nil {
candidates = append(candidates, scanCandidates...)
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
}
}
return readLargestJPEGPreview(f, candidates)
return nil, nil
}
type previewCandidate struct {
@@ -132,26 +140,6 @@ 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) {
@@ -1,272 +0,0 @@
//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 ""
}
}
@@ -1,253 +0,0 @@
//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,7 +4,6 @@ package imagemeta
import (
"context"
"io"
"os"
"path/filepath"
"strings"
@@ -28,21 +27,9 @@ 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)
-248
View File
@@ -1,248 +0,0 @@
//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
}
}
@@ -1,195 +0,0 @@
//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
@@ -1 +0,0 @@
gofitzinclude
+57 -22
View File
@@ -3,15 +3,18 @@
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 {
@@ -20,73 +23,105 @@ func docEnabled() bool {
return true
}
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int, previewSize string) ([]byte, error) {
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]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 {
logger.Errorf("text snippet preview: failed to open file '%s': %v", file.Name, err)
return nil, fmt.Errorf("text snippet: failed to open file")
return nil, fmt.Errorf("text snippet: failed to open original file '%s': %w", file.RealPath, err)
}
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
logger.Errorf("text snippet preview: failed to read file '%s': %v", file.Name, readErr)
return nil, fmt.Errorf("text snippet: failed to read file")
return nil, fmt.Errorf("text snippet: failed to read from original file '%s': %w", file.RealPath, readErr)
}
if n == 0 {
return nil, fmt.Errorf("text snippet: file is empty or unreadable")
return nil, fmt.Errorf("text snippet: original file '%s' is empty or unreadable", file.RealPath)
} else {
tempFile, err := os.Create(tempFilePath)
if err != nil {
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")
return nil, fmt.Errorf("text snippet: failed to create temporary file '%s': %w", tempFilePath, err)
}
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
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")
return nil, fmt.Errorf("text snippet: failed to write to temporary file '%s': %w", tempFilePath, err)
}
// 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
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")
return nil, fmt.Errorf("text snippet: failed to close temporary file '%s': %w", tempFilePath, err)
}
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
}
}
timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// Check timeout before opening document
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
}
imageBytes, err := s.renderDocPageJPEG(timeoutCtx, docPath, pageNumber, previewSize)
doc, err := fitz.New(docPath) // This calls the CGo version
if err != nil {
logger.Errorf("document preview render failed for '%s': %v", file.Name, err)
return nil, fmt.Errorf("failed to create document preview")
return nil, fmt.Errorf("failed to open PDF from memory for file '%s': %w", docPath, err)
}
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)
}
return imageBytes, nil
// 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
}
+1 -2
View File
@@ -14,8 +14,7 @@ func docEnabled() bool {
return false
}
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int, previewSize string) ([]byte, error) {
_ = previewSize
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
// Reference it to prevent unused field warning when building without mupdf
_ = &s.docGenMutex
return nil, nil
-7
View File
@@ -1,7 +0,0 @@
//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
@@ -1,272 +0,0 @@
//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
}
+21 -31
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/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/filebrowser/backend/internal/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
"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, previewSize, hash)
return s.generateDocumentPreview(ctx, file, hash)
case previewTypeOffice:
return s.generateOfficeFilePreview(ctx, file, officeUrl)
@@ -270,12 +270,6 @@ 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) && s.ffmpegService != nil {
if bytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize); err == nil && len(bytes) >= minPreviewSize {
return bytes, nil
}
}
return s.generateImagePreview(ctx, file, previewSize)
case previewTypeVideo:
@@ -296,9 +290,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, previewSize, hash string) ([]byte, error) {
func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, hash string) ([]byte, error) {
tempFilePath := filepath.Join(s.cacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0, previewSize) // 0 for the first page
imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
@@ -421,26 +415,23 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, ctx.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()
// 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
}
defer service.releaseImageLargeSem()
} else {
if err := service.acquireImageSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageSem()
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Check if context is cancelled after acquiring semaphore
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Enforce file size limit for image preview generation to prevent memory exhaustion
@@ -491,8 +482,7 @@ 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 ||
(previewType == previewTypeDocument && settings.Env.MuPdfAvailable)) {
if !fromEmbeddedPreview && previewType == previewTypeImage {
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
}
-1
View File
@@ -69,7 +69,6 @@ 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,6 +34,10 @@ 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,9 +53,6 @@ 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 {
@@ -112,14 +109,6 @@ 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) {
+3 -4
View File
@@ -6,10 +6,9 @@ import (
)
type SubtitleTrack struct {
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
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
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,7 +1,6 @@
package utils
import (
"path"
"path/filepath"
"runtime"
"strings"
@@ -42,38 +41,6 @@ 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
@@ -1,67 +0,0 @@
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)
}
}
+6 -10
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/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
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,17 +144,13 @@ 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 {
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
}
@@ -228,7 +224,7 @@ 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)
}
+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.JoinScopedIndexPath(d.Share.Path, cleanFile)
filePath := utils.JoinPathAsUnix(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.JoinScopedIndexPath(userscope, filePath)
fileList[i] = utils.JoinPathAsUnix(userscope, filePath)
}
}
firstFilePath = fileList[0]
-2
View File
@@ -127,8 +127,6 @@ 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:
+3 -4
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/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)))
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))
// ========================================
// Tools Routes - /api/tools/
@@ -143,7 +143,6 @@ 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)))
+5 -1
View File
@@ -66,7 +66,11 @@ func authenticateLDAP(username, password string) ([]string, map[string]string, e
logger.Debugf("ldap: no service account bind (userPassword empty)")
}
groupAttr := settings.ResolveGroupsClaim(c.GroupsClaim)
// Build list of attributes to fetch
groupAttr := c.GroupsClaim
if groupAttr == "" {
groupAttr = "memberOf"
}
attributes := []string{"dn", groupAttr}
if c.UserIdentifier != "" {
attributes = append(attributes, c.UserIdentifier)
+17 -63
View File
@@ -13,6 +13,7 @@ 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
@@ -32,18 +33,17 @@ 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,47 +52,18 @@ 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")
}
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)
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)
if err != nil {
return ErrToStatus(err), err
}
@@ -318,20 +289,3 @@ 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)
}
+7 -8
View File
@@ -125,7 +125,6 @@ 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")) {
@@ -202,10 +201,7 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
data.Token = tokenString
var tk users.AuthToken
// 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)
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
if err != nil {
return nil
}
@@ -214,9 +210,8 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
return nil
}
if state.IsTokenRevoked(tokenString) {
return nil
}
// Token is valid (but might be expired or revoked)
// Try to get the user regardless of expiration status
var user *users.User
userValue, err := state.UserFromAPIToken(tk, tokenString)
if err == nil {
@@ -460,6 +455,10 @@ 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
-169
View File
@@ -149,175 +149,6 @@ 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)
-5
View File
@@ -272,11 +272,6 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
// 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
}
}
}
}
+6 -23
View File
@@ -213,13 +213,11 @@ 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, shareToken)
documentURL := buildOnlyOfficeViewURL(r, source, providedPath, d.FileInfo.Hash, viewToken, d.Token)
// Build callback URL for OnlyOffice to notify us of changes
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.FileInfo.Hash, d.Token, shareToken)
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.FileInfo.Hash, d.Token)
// Build OnlyOffice client configuration
clientConfig := map[string]interface{}{
@@ -298,26 +296,14 @@ 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, shareToken string) string {
func buildOnlyOfficeViewURL(r *http.Request, source, path, hash, viewToken, authToken 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)
@@ -326,7 +312,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, authToken, shareToken string) string {
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token string) string {
baseURL := onlyOfficeFileBrowserBaseURL(r)
params := url.Values{}
@@ -334,17 +320,14 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, authToken,
// 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", authToken)
if shareToken != "" {
params.Set("token", shareToken)
}
params.Set("auth", token)
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", authToken)
params.Set("auth", token)
return joinOnlyOfficeAPIURL(baseURL, "api/office/callback") + "?" + params.Encode()
}
+2 -80
View File
@@ -5,7 +5,6 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
@@ -61,96 +60,19 @@ 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
+76 -205
View File
@@ -2,6 +2,8 @@ package web
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -23,9 +25,10 @@ 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)
@@ -390,7 +393,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: sanitizedPath,
Message: err.Error(),
})
continue
@@ -401,7 +404,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if sourceName == "" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: sanitizedPath,
Message: "source name is empty",
})
continue
@@ -411,7 +414,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if idx == nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: sanitizedPath,
Message: "source not found",
})
continue
@@ -420,7 +423,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: rawPath,
Path: sanitizedPath,
Message: "source is read-only",
})
continue
@@ -431,13 +434,13 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: sanitizedPath,
Message: "user does not have access",
})
continue
}
withoutUserScope := strings.TrimPrefix(d.Share.Path, userScope)
indexPath := utils.JoinScopedIndexPath(withoutUserScope, sanitizedPath)
indexPath := utils.JoinPathAsUnix(withoutUserScope, sanitizedPath)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
@@ -455,7 +458,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: rawPath,
Path: sanitizedPath,
Message: "error deleting file/directory, admin must check the logs",
})
continue
@@ -467,7 +470,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if item.Source == "" {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: sanitizedPath,
Message: "source was empty, source is required",
})
continue
@@ -478,7 +481,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: sanitizedPath,
Message: fmt.Sprintf("user does not have access: %v", err),
})
continue
@@ -487,7 +490,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: rawPath,
Path: sanitizedPath,
Message: "user is not allowed to delete",
})
continue
@@ -497,7 +500,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if idx == nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: sanitizedPath,
Message: "source not found",
})
continue
@@ -506,7 +509,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: rawPath,
Path: sanitizedPath,
Message: "source is read-only",
})
continue
@@ -522,7 +525,7 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: sanitizedPath,
Message: err.Error(),
})
continue
@@ -531,18 +534,16 @@ func ResourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *Contex
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
Path: rawPath,
Path: sanitizedPath,
Message: err.Error(),
})
continue
}
preview.DelThumbs(r.Context(), *fileInfo)
}
// Success — echo the client path in the response
loggedItem := BulkDeleteItem{
Source: item.Source,
Path: rawPath,
}
// Success (log canonical path/source used for deletion)
loggedItem := item
loggedItem.Path = sanitizedPath
if d.Share.Hash != "" {
loggedItem.Source = d.Share.GetSourceName()
}
@@ -628,7 +629,7 @@ func resourcePauseHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinScopedIndexPath(userscope, cleanPath)
fullIndexPath := utils.JoinPathAsUnix(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)
}
@@ -717,7 +718,7 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
}
userscope = strings.TrimRight(userscope, "/")
fullIndexPath := utils.JoinScopedIndexPath(userscope, path)
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
// get scoped path
realPath, _, _ := idx.GetRealPath(fullIndexPath)
@@ -781,34 +782,12 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
}
var totalSize int64
var hasTotalSize bool
totalSize, hasTotalSize, err = parseUploadTotalSize(r)
totalSizeStr := r.Header.Get("X-File-Total-Size")
totalSize, err = strconv.ParseInt(totalSizeStr, 10, 64)
if err != nil {
logger.Debugf("%v", err)
return http.StatusBadRequest, err
logger.Debugf("invalid total size: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid total size: %v", 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)
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
@@ -835,8 +814,11 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
}
}
// Use a temporary file for chunks.
tempFilePath := uploadTempPath(realPath, sessionID)
// 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)))
// Create or open the temporary file
var outFile *os.File
outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, fileutils.PermFile)
@@ -878,51 +860,15 @@ 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
}
logger.Debugf("chunk upload failed; keeping partial file for resume (source=%s path=%s offset=%d)", source, path, offset)
_ = os.Remove(tempFilePath)
return http.StatusInternalServerError, fmt.Errorf("could not write chunk to temp file: %v", err)
}
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())
}
// check if the file is complete
if (offset + chunkSize) >= totalSize {
// close file before moving
outFile.Close()
// Move the completed file from the temp location to the final destination
@@ -933,7 +879,6 @@ 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)
}
return http.StatusOK, nil
}
@@ -948,82 +893,12 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
preview.DelThumbs(r.Context(), *fileInfo)
}
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)
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
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
}
@@ -1109,7 +984,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinScopedIndexPath(userScope, path)
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
// Check access control for the target path
idx := indexing.GetIndex(source)
if idx == nil {
@@ -1163,7 +1038,7 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
return http.StatusBadRequest, err
}
resolvedPath := utils.JoinScopedIndexPath(d.Share.Path, cleanPath)
resolvedPath := utils.JoinPathAsUnix(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)
@@ -1215,11 +1090,9 @@ 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 == "" || clientFromPath == "" || item.ToSource == "" || clientToPath == "" {
if item.FromSource == "" || item.FromPath == "" || item.ToSource == "" || item.ToPath == "" {
item.Message = "fromSource, fromPath, toSource, and toPath are required"
if d.Share.Hash != "" {
response.Failed = append(response.Failed, MoveCopyItem{
@@ -1227,21 +1100,23 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
cleanFromPath, err := utils.SanitizePath(clientFromPath)
cleanFromPath, err := utils.SanitizePath(item.FromPath)
if err != nil {
item.Message = fmt.Sprintf("invalid fromPath: %v", err)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
cleanToPath, err := utils.SanitizePath(clientToPath)
cleanToPath, err := utils.SanitizePath(item.ToPath)
if err != nil {
item.Message = fmt.Sprintf("invalid toPath: %v", err)
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
item.FromPath = cleanFromPath
item.ToPath = cleanToPath
// Get user scopes for both sources
// For shares, paths are already absolute, so use empty scope
@@ -1251,19 +1126,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, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
userscopeDst, err = d.User.GetScopeForSourceName(item.ToSource)
if err != nil {
item.Message = "destination source not available"
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
fromPerms, permErr := effectiveFilePerms(d, item.FromSource)
if permErr != nil {
item.Message = "permission denied"
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
toPerms := fromPerms
@@ -1272,13 +1147,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, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
}
if msg := resourcePatchPermCheck(req.Action, item.FromSource, item.ToSource, fromPerms, toPerms); msg != "" {
item.Message = msg
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
}
@@ -1293,7 +1168,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
@@ -1307,7 +1182,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
@@ -1319,7 +1194,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
@@ -1331,16 +1206,16 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
// Build full index paths for access control
fullSrcIndexPath := utils.JoinScopedIndexPath(userscopeSrc, cleanFromPath)
fullDstIndexPath := utils.JoinScopedIndexPath(userscopeDst, cleanToPath)
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
if fullDstIndexPath == "/" || fullSrcIndexPath == "/" {
item.Message = "source or destination is the root or unautharized directory"
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
@@ -1353,7 +1228,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
if !state.AccessPermitted(dstIdx.Path, utils.IndexPathFromNormalized(fullDstIndexPath, true), d.User.Username) {
@@ -1364,14 +1239,16 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
// Get real paths (fullSrcIndexPath already includes user scope)
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcIndexPath)
// 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)
if err != nil {
logger.Errorf("could not resolve source path: %v, fromPath: %v", err, clientFromPath)
logger.Errorf("could not resolve source path: %v, item.FromPath: %v", err, item.FromPath)
item.Message = "could not resolve source path"
if d.Share.Hash != "" {
response.Failed = append(response.Failed, MoveCopyItem{
@@ -1379,13 +1256,13 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
// Check destination parent directory exists
dstParentPath := filepath.Dir(cleanToPath)
fullDstParentPath := utils.JoinScopedIndexPath(userscopeDst, dstParentPath)
dstParentPath := filepath.Dir(item.ToPath)
fullDstParentPath := utils.JoinPathAsUnix(userscopeDst, dstParentPath)
parentDir, _, err := dstIdx.GetRealPath(fullDstParentPath)
if err != nil {
item.Message = "destination directory does not exist"
@@ -1395,10 +1272,10 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
realDest := parentDir + "/" + filepath.Base(cleanToPath)
realDest := parentDir + "/" + filepath.Base(item.ToPath)
// Auto-rename if requested
if req.Rename {
@@ -1415,7 +1292,7 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
})
continue
}
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
}
@@ -1440,13 +1317,13 @@ func ResourcePatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (i
continue
}
item.Message = err.Error()
response.Failed = append(response.Failed, moveCopyWithClientPaths(item, clientFromPath, clientToPath))
response.Failed = append(response.Failed, item)
continue
}
// Success
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})
response.Succeeded = append(response.Succeeded, item)
activity.RecordPatchItem(r, toActor(d), req.Action, activity.MoveCopyItem{FromSource: item.FromSource, FromPath: item.FromPath, ToPath: item.ToPath})
}
if len(response.Failed) == 0 && len(response.Succeeded) == 0 {
@@ -1534,13 +1411,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.JoinScopedIndexPath(d.Share.Path, sanitizedPath)
req.Items[i].FromPath = utils.JoinPathAsUnix(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.JoinScopedIndexPath(d.Share.Path, sanitizedPath)
req.Items[i].ToPath = utils.JoinPathAsUnix(d.Share.Path, sanitizedPath)
}
d.Data = req
@@ -1718,12 +1595,6 @@ 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 {
@@ -1,562 +0,0 @@
package web
import (
"bytes"
"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)
}
}
+1 -3
View File
@@ -280,9 +280,6 @@ 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)
@@ -310,6 +307,7 @@ 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
@@ -1,186 +0,0 @@
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)
}
}
+3 -21
View File
@@ -14,7 +14,6 @@ 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"
@@ -434,25 +433,8 @@ func ServeSingleFile(w http.ResponseWriter, r *http.Request, d *Context, source
return http.StatusForbidden, fmt.Errorf("access denied to path %s", 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)
realPath, _, err := idx.GetRealPath(scopedFilePath)
if err != nil {
if errors.Is(err, liberrors.ErrPathEscapesScope) {
return http.StatusForbidden, err
}
return http.StatusInternalServerError, err
}
@@ -571,7 +553,7 @@ func streamHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, err
if err != nil {
return http.StatusForbidden, err
}
scopedPath := utils.JoinScopedIndexPath(userscope, cleanPath)
scopedPath := utils.JoinPathAsUnix(userscope, cleanPath)
return streamFilesHandler(w, r, d, source, []string{scopedPath})
}
@@ -618,7 +600,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.JoinScopedIndexPath(d.Share.Path, cleanFile)
scopedPath := utils.JoinPathAsUnix(d.Share.Path, cleanFile)
status, err := streamFilesHandler(w, r, d, sourceInfo.Name, []string{scopedPath})
if err != nil {
if status == http.StatusForbidden {
-88
View File
@@ -1,88 +0,0 @@
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
}
-98
View File
@@ -1,98 +0,0 @@
package web
import (
"crypto/md5"
"encoding/hex"
"fmt"
"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
}
+3
View File
@@ -43,6 +43,9 @@ 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>")
}
+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.JoinScopedIndexPath(userscope, cleanPath)
scopedPath := utils.JoinPathAsUnix(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.JoinScopedIndexPath(d.Share.Path, cleanFile)
scopedPath := utils.JoinPathAsUnix(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,11 +1,9 @@
package main
import (
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/cmd"
)
func main() {
jwt.DecodeStrict = true
cmd.StartFilebrowser()
}
+16 -97
View File
@@ -428,7 +428,6 @@ 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
@@ -436,64 +435,10 @@ 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, boundIndexPath string) (*PathContext, error) {
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*PathContext, error) {
realPath := filepath.Join(idx.Path, indexPath)
// ONE filesystem stat call
fileInfo, err := os.Lstat(realPath)
@@ -505,7 +450,7 @@ func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool, boun
// Resolve symlink if requested
if isSymlink && followSymlinks {
realPath, err = idx.resolveSymlinksContained(realPath, boundIndexPath)
realPath, err = filepath.EvalSymlinks(realPath)
if err != nil {
return nil, err
}
@@ -561,7 +506,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, req.BoundIndexPath)
ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks)
if err != nil {
return nil, err
}
@@ -649,7 +594,7 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
if !strings.HasSuffix(indexPath, "/") {
indexPath = indexPath + "/"
}
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
realPath := filepath.Join(idx.Path, indexPath)
// Open the directory
dir, err := os.Open(realPath)
@@ -687,10 +632,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 := utils.JoinUnderSourceRoot(idx.Path, indexPath)
realPath := filepath.Join(idx.Path, indexPath)
if opts.FollowSymlinks {
var err error
realPath, err = idx.resolveSymlinksContained(realPath, "/")
realPath, err = filepath.EvalSymlinks(realPath)
if err != nil {
return nil, err
}
@@ -1046,28 +991,10 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, indexPath strin
}
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
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)
combined := append([]string{idx.Path}, relativePath...)
joinedPath := filepath.Join(combined...)
isDir, _ := IsDirCache.Get(joinedPath + ":isdir")
cached, ok := RealPathCache.Get(joinedPath)
if ok && cached != "" {
return cached, isDir, nil
}
@@ -1076,19 +1003,11 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
}
realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath)
if err != nil {
return realPath, isDir, err
if err == nil {
RealPathCache.Set(joinedPath, realPath)
IsDirCache.Set(joinedPath+":isdir", isDir)
}
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
return realPath, isDir, err
}
func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
@@ -1115,7 +1034,7 @@ func (idx *Index) RefreshDirectory(indexPath string, recursive bool) error {
indexPath = indexPath + "/"
}
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
realPath := filepath.Join(idx.Path, indexPath)
// Check if directory exists
dirInfo, err := os.Stat(realPath)
-29
View File
@@ -105,35 +105,6 @@ 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) {
@@ -1,178 +0,0 @@
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 = utils.JoinUnderSourceRoot(idx.Path, indexPath)
realFilePath = filepath.Join(idx.Path, indexPath)
}
sys := file.Sys()
+6 -3
View File
@@ -149,7 +149,6 @@ func ValidateLdapAuth() error {
if ldapCfg.UserFilter == "" {
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
}
applyAuthCommonDefaults(&ldapCfg.AuthCommon)
if err := verifyLdapConnection(); err != nil {
logger.Fatalf("LDAP connection check failed: %v", err)
}
@@ -159,7 +158,9 @@ func ValidateLdapAuth() error {
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
func validateOidcAuth() error {
oidcCfg := &Config.Auth.Methods.OidcAuth // Use a pointer to modify the original config
applyAuthCommonDefaults(&oidcCfg.AuthCommon)
if oidcCfg.GroupsClaim == "" {
oidcCfg.GroupsClaim = "groups"
}
if oidcCfg.UserIdentifier == "" {
oidcCfg.UserIdentifier = "preferred_username"
}
@@ -288,7 +289,9 @@ func ValidateJwtAuth() error {
if jwtCfg.Algorithm == "" {
jwtCfg.Algorithm = "HS256"
}
applyAuthCommonDefaults(&jwtCfg.AuthCommon)
if jwtCfg.GroupsClaim == "" {
jwtCfg.GroupsClaim = "groups"
}
if jwtCfg.UserIdentifier == "" {
jwtCfg.UserIdentifier = "sub"
}
-16
View File
@@ -1,16 +0,0 @@
package settings
const defaultGroupsClaim = "groups"
// ResolveGroupsClaim returns the configured groups claim or the shared default when empty.
func ResolveGroupsClaim(claim string) string {
if claim == "" {
return defaultGroupsClaim
}
return claim
}
// applyAuthCommonDefaults sets shared defaults for external auth methods that support group claims.
func applyAuthCommonDefaults(c *AuthCommon) {
c.GroupsClaim = ResolveGroupsClaim(c.GroupsClaim)
}
-26
View File
@@ -1,26 +0,0 @@
package settings
import "testing"
func TestApplyAuthCommonDefaults(t *testing.T) {
empty := AuthCommon{}
applyAuthCommonDefaults(&empty)
if empty.GroupsClaim != "groups" {
t.Fatalf("default groupsClaim = %q, want groups", empty.GroupsClaim)
}
custom := AuthCommon{GroupsClaim: "memberOf"}
applyAuthCommonDefaults(&custom)
if custom.GroupsClaim != "memberOf" {
t.Fatalf("custom groupsClaim = %q, want memberOf", custom.GroupsClaim)
}
}
func TestResolveGroupsClaim(t *testing.T) {
if got := ResolveGroupsClaim(""); got != "groups" {
t.Fatalf("ResolveGroupsClaim(\"\") = %q, want groups", got)
}
if got := ResolveGroupsClaim("memberOf"); got != "memberOf" {
t.Fatalf("ResolveGroupsClaim(memberOf) = %q, want memberOf", got)
}
}
-1
View File
@@ -515,7 +515,6 @@ func setupAuth(generate bool) {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
}
if Config.Auth.Methods.ProxyAuth.Enabled {
applyAuthCommonDefaults(&Config.Auth.Methods.ProxyAuth.AuthCommon)
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
}
if Config.Auth.Methods.NoAuth {
+17 -56
View File
@@ -71,49 +71,6 @@ func AdminSourceFilePermissions() users.SourceFilePermissions {
}
}
// MergeDefaultEnabledBackendScopes ensures every defaultEnabled source is present in
// BackendScopes. Existing scopes for configured sources are preserved (empty Scope is
// filled from DefaultUserScope). Unknown paths not in Config.Server.Sources are kept
// at the end. defaultEnabled: false sources are never auto-added.
func MergeDefaultEnabledBackendScopes(existing []users.BackendScope) []users.BackendScope {
byPath := make(map[string]users.BackendScope, len(existing))
for _, s := range existing {
byPath[s.Path] = s
}
newScopes := make([]users.BackendScope, 0, len(existing)+len(Config.Server.Sources))
seen := make(map[string]struct{}, len(Config.Server.Sources))
for _, src := range Config.Server.Sources {
if src == nil {
continue
}
existingScope, ok := byPath[src.Path]
if ok {
if existingScope.Scope == "" {
existingScope.Scope = src.Config.DefaultUserScope
}
} else if src.Config.DefaultEnabled {
existingScope = users.BackendScope{
Scope: src.Config.DefaultUserScope,
}
} else {
continue
}
newScopes = append(newScopes, users.BackendScope{
Path: src.Path,
Scope: existingScope.Scope,
Permissions: existingScope.Permissions,
})
seen[src.Path] = struct{}{}
}
for _, s := range existing {
if _, ok := seen[s.Path]; !ok {
newScopes = append(newScopes, s)
}
}
return newScopes
}
// ApplyUserDefaults applies Config.UserDefaults to a user (tests and legacy callers).
func ApplyUserDefaults(u *users.User) {
ApplyUserDefaultsFrom(u, Config.UserDefaults)
@@ -137,20 +94,24 @@ func ApplyUserDefaultsFrom(u *users.User, d UserDefaults) {
sourceDefaults := DefaultSourceFilePermissions()
u.BackendScopes = MergeDefaultEnabledBackendScopes(u.BackendScopes)
if len(u.SidebarLinks) == 0 && len(u.BackendScopes) > 0 {
scope := u.BackendScopes[0]
name := scope.Path
if src := Config.Server.SourceMap[scope.Path]; src != nil && src.Name != "" {
name = src.Name
if len(u.BackendScopes) == 0 {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
u.BackendScopes = append(u.BackendScopes, users.BackendScope{
Path: source.Path,
Scope: source.Config.DefaultUserScope,
})
if len(u.SidebarLinks) == 0 {
u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{
Name: source.Name,
Category: "source",
Target: "/",
Icon: "",
SourceName: source.Path,
})
}
}
}
u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{
Name: name,
Category: "source",
Target: "/",
Icon: "",
SourceName: scope.Path,
})
}
if u.BackendSourcePermissions == nil {
-168
View File
@@ -1,168 +0,0 @@
// Setup go-fitz MuPDF headers for preview CGO (-tags=mupdf).
// Run from the backend module root: go run ./scripts/setup-gofitz-cgo
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
)
func main() {
backendDir, err := backendRoot()
if err != nil {
fatal(err)
}
outPath := filepath.Join(backendDir, "internal", "preview", "gofitzinclude")
includeDir, err := goFitzIncludeDir(backendDir)
if err != nil {
fatal(err)
}
if err := removeExisting(outPath); err != nil {
fatal(err)
}
if err := linkInclude(includeDir, outPath); err != nil {
fatal(err)
}
fmt.Println("linked go-fitz MuPDF headers at", outPath)
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
func backendRoot() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil {
return wd, nil
}
backend := filepath.Join(wd, "backend")
if _, err := os.Stat(filepath.Join(backend, "go.mod")); err == nil {
return backend, nil
}
return "", fmt.Errorf("could not find backend module root from %s", wd)
}
func goFitzIncludeDir(backendDir string) (string, error) {
cmd := exec.Command("go", "mod", "download", "-json", "github.com/gen2brain/go-fitz")
cmd.Dir = backendDir
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("go mod download go-fitz: %w: %s", err, ee.Stderr)
}
return "", fmt.Errorf("go mod download go-fitz: %w", err)
}
var modInfo struct {
Dir string `json:"Dir"`
}
if err := json.Unmarshal(out, &modInfo); err != nil {
return "", fmt.Errorf("parse go mod download output: %w", err)
}
if modInfo.Dir == "" {
return "", fmt.Errorf("go-fitz module path not found after download")
}
include := filepath.Join(modInfo.Dir, "include")
if fi, err := os.Stat(include); err != nil || !fi.IsDir() {
return "", fmt.Errorf("go-fitz include directory not found: %s", include)
}
return include, nil
}
func removeExisting(path string) error {
fi, err := os.Lstat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if fi.Mode()&os.ModeSymlink != 0 {
return os.Remove(path)
}
if runtime.GOOS == "windows" {
// Junction points are removed with rmdir without deleting the target directory.
if err := exec.Command("cmd", "/c", "rmdir", path).Run(); err == nil {
return nil
}
}
return os.RemoveAll(path)
}
func linkInclude(src, dst string) error {
absSrc, err := filepath.Abs(src)
if err != nil {
return err
}
absDst, err := filepath.Abs(dst)
if err != nil {
return err
}
if runtime.GOOS != "windows" {
return os.Symlink(absSrc, absDst)
}
if err := os.Symlink(absSrc, absDst); err == nil {
return nil
}
if err := exec.Command("cmd", "/c", "mklink", "/J", absDst, absSrc).Run(); err == nil {
return nil
}
return copyDir(absSrc, absDst)
}
func copyDir(src, dst string) error {
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
return filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(path, target)
})
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err = os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
@@ -1,125 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestCopyFile(t *testing.T) {
t.Parallel()
srcDir := t.TempDir()
dstDir := t.TempDir()
src := filepath.Join(srcDir, "header.h")
if err := os.WriteFile(src, []byte("test content"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
dst := filepath.Join(dstDir, "nested", "header.h")
if err := copyFile(src, dst); err != nil {
t.Fatalf("copyFile: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read destination: %v", err)
}
if string(got) != "test content" {
t.Fatalf("content = %q, want %q", string(got), "test content")
}
}
func TestCopyDir_nestedFiles(t *testing.T) {
t.Parallel()
srcDir := t.TempDir()
dstDir := t.TempDir()
nested := filepath.Join(srcDir, "mupdf")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatalf("mkdir nested: %v", err)
}
if err := os.WriteFile(filepath.Join(srcDir, "root.txt"), []byte("root"), 0o644); err != nil {
t.Fatalf("write root: %v", err)
}
if err := os.WriteFile(filepath.Join(nested, "fitz.h"), []byte("header"), 0o644); err != nil {
t.Fatalf("write nested: %v", err)
}
if err := copyDir(srcDir, dstDir); err != nil {
t.Fatalf("copyDir: %v", err)
}
root, err := os.ReadFile(filepath.Join(dstDir, "root.txt"))
if err != nil {
t.Fatalf("read root copy: %v", err)
}
if string(root) != "root" {
t.Fatalf("root content = %q, want root", string(root))
}
nestedCopy, err := os.ReadFile(filepath.Join(dstDir, "mupdf", "fitz.h"))
if err != nil {
t.Fatalf("read nested copy: %v", err)
}
if string(nestedCopy) != "header" {
t.Fatalf("nested content = %q, want header", string(nestedCopy))
}
}
func TestRemoveExisting_directory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
target := filepath.Join(dir, "gofitzinclude")
if err := os.MkdirAll(filepath.Join(target, "mupdf"), 0o755); err != nil {
t.Fatalf("mkdir target: %v", err)
}
if err := os.WriteFile(filepath.Join(target, "mupdf", "fitz.h"), []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
if err := removeExisting(target); err != nil {
t.Fatalf("removeExisting: %v", err)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("target still exists after removeExisting: %v", err)
}
}
func TestRemoveExisting_symlink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
src := filepath.Join(dir, "include")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatalf("mkdir src: %v", err)
}
link := filepath.Join(dir, "gofitzinclude")
if err := os.Symlink(src, link); err != nil {
t.Skipf("symlinks not supported: %v", err)
}
if err := removeExisting(link); err != nil {
t.Fatalf("removeExisting symlink: %v", err)
}
if _, err := os.Lstat(link); !os.IsNotExist(err) {
t.Fatalf("symlink still exists: %v", err)
}
if _, err := os.Stat(src); err != nil {
t.Fatalf("symlink target removed: %v", err)
}
}
func TestRemoveExisting_missingPath(t *testing.T) {
t.Parallel()
dir := t.TempDir()
missing := filepath.Join(dir, "missing")
if err := removeExisting(missing); err != nil {
t.Fatalf("removeExisting missing: %v", err)
}
}
-51
View File
@@ -4003,53 +4003,6 @@ const docTemplate = `{
}
}
},
"/public/api/media/subtitles": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"Media"
],
"summary": "Get subtitle content (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
},
{
"type": "string",
"description": "Subtitle track name",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream",
"name": "embedded",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content",
"schema": {
"type": "string"
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -7943,10 +7896,6 @@ const docTemplate = `{
"description": "filename for external, or descriptive name for embedded",
"type": "string"
},
"srclang": {
"description": "unique HTML track language tag for Plyr (may differ from Language when duplicated)",
"type": "string"
},
"title": {
"description": "title/description",
"type": "string"
-51
View File
@@ -3992,53 +3992,6 @@
}
}
},
"/public/api/media/subtitles": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"Media"
],
"summary": "Get subtitle content (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
},
{
"type": "string",
"description": "Subtitle track name",
"name": "name",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "Whether this is an embedded stream",
"name": "embedded",
"in": "query"
}
],
"responses": {
"200": {
"description": "Raw subtitle content",
"schema": {
"type": "string"
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -7932,10 +7885,6 @@
"description": "filename for external, or descriptive name for embedded",
"type": "string"
},
"srclang": {
"description": "unique HTML track language tag for Plyr (may differ from Language when duplicated)",
"type": "string"
},
"title": {
"description": "title/description",
"type": "string"
-35
View File
@@ -2067,10 +2067,6 @@ definitions:
name:
description: filename for external, or descriptive name for embedded
type: string
srclang:
description: unique HTML track language tag for Plyr (may differ from Language
when duplicated)
type: string
title:
description: title/description
type: string
@@ -5074,37 +5070,6 @@ paths:
summary: Stream a single media file from a public share for inline viewing
tags:
- Resources
/public/api/media/subtitles:
get:
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share
in: query
name: path
type: string
- description: Subtitle track name
in: query
name: name
required: true
type: string
- description: Whether this is an embedded stream
in: query
name: embedded
type: boolean
produces:
- text/plain
responses:
"200":
description: Raw subtitle content
schema:
type: string
summary: Get subtitle content (public share)
tags:
- Media
/public/api/resources:
get:
consumes:
+323 -358
View File
File diff suppressed because it is too large. Load diff
+7 -26
View File
@@ -4,35 +4,16 @@ import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { adjustedData, fetchURL } from "./utils";
import { getCachedDirMetadata, setCachedDirMetadata } from "@/utils/metadataCache.js";
// GET /api/media/subtitles or /public/api/media/subtitles
// GET /api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
try {
const hash = state.shareInfo?.hash || null
const baseParams = {
path,
const apiPath = getApiPath('media/subtitles', {
source: source,
path: path,
name: subtitleName,
...(embedded && { embedded: 'true' }),
}
let res
if (hash) {
const apiPath = getPublicApiPath('media/subtitles', {
hash,
...baseParams,
...(state.shareInfo.token && { token: state.shareInfo.token }),
})
const sharePassword = localStorage.getItem(`sharepass:${hash}`) || ''
res = await fetchURL(apiPath, {
headers: { 'X-SHARE-PASSWORD': sharePassword },
})
} else {
const apiPath = getApiPath('media/subtitles', {
source,
...baseParams,
})
res = await fetchURL(apiPath)
}
embedded: embedded.toString()
})
const res = await fetchURL(apiPath)
const content = await res.text()
return content
} catch (err) {
+2 -12
View File
@@ -1,5 +1,4 @@
import { notify } from '@/notify'
import { state } from '@/store'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { fetchURL } from './utils'
@@ -9,16 +8,7 @@ export async function getConfig(req) {
const params = {
path: req.path,
...(req.hash && { hash: req.hash }),
...(req.source && { source: req.source }),
...(req.hash && state.shareInfo?.token && { token: state.shareInfo.token }),
}
const headers = {}
if (req.hash) {
const sharePassword = localStorage.getItem(`sharepass:${req.hash}`)
if (sharePassword) {
headers['X-SHARE-PASSWORD'] = sharePassword
}
...(req.source && { source: req.source })
}
let apiPath
@@ -28,7 +18,7 @@ export async function getConfig(req) {
apiPath = getApiPath('office/config', params)
}
const res = await fetchURL(apiPath, { headers })
const res = await fetchURL(apiPath)
return await res.json()
} catch (err) {
notify.showError(err.message || 'Error fetching OnlyOffice configuration')
+48 -116
View File
@@ -6,7 +6,6 @@ import {
notifyDownloadComplete,
notifyDownloadError,
} from '@/utils/appNotifications'
import { renew } from '@/utils/auth'
import { getApiPath, getPublicApiPath, getParentDir } from '@/utils/url.js'
import { adjustedData, fetchURL } from './utils'
import { rememberViewToken } from './viewToken'
@@ -143,34 +142,29 @@ export async function bulkDelete(items) {
}
try {
const apiPath = getApiPath('resources/bulk')
const response = await fetch(apiPath, {
const response = await fetchURL(apiPath, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'sessionId': state.sessionId,
},
credentials: 'same-origin',
body: JSON.stringify(items),
})
const data = await response.json()
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
// Both are valid responses that should be returned, not thrown as errors
if (response.status === 200 || response.status === 207) {
items.forEach((item) => {
invalidateDirMetadataCache({ source: item.source, path: getParentDir(item.path) })
})
return data
}
// For other error status codes, throw an error
const error = new Error(data.message || response.statusText)
error.status = response.status
if (data.failed) {
error.failed = data.failed
}
if (data.succeeded) {
error.succeeded = data.succeeded
}
throw error
} catch (err) {
if (!err.status || (err.status !== 500 && err.status !== 200 && err.status !== 207)) {
// Only show notification and re-throw if it's a real error (not 200/207)
if (err.status && err.status !== 200 && err.status !== 207) {
notify.showError(err.message || 'Error performing bulk delete')
}
throw err
@@ -701,79 +695,33 @@ export function post(
...(isDir && { isDir: 'true' })
});
const opState = { xhr: null, aborted: false };
const request = new XMLHttpRequest();
request.open("POST", apiPath, true);
const rejectIfAborted = (reject) => {
if (!opState.aborted) {
return false;
}
reject(new Error("Upload aborted"));
return true;
};
Object.entries(headers).forEach(([header, value]) => {
request.setRequestHeader(header, value);
});
const startRequest = (isRetry) =>
new Promise((resolve, reject) => {
if (rejectIfAborted(reject)) {
return;
if (typeof onupload === "function") {
request.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = Math.round(
(event.loaded / event.total) * 100
);
onupload(percentComplete); // Pass the percentage to the callback
}
const request = new XMLHttpRequest();
opState.xhr = request;
request.open("POST", apiPath, true);
};
}
Object.entries(headers).forEach(([header, value]) => {
request.setRequestHeader(header, value);
});
if (typeof onupload === "function") {
request.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = Math.round(
(event.loaded / event.total) * 100
);
onupload(percentComplete);
}
};
}
request.onload = () => {
if (request.status >= 200 && request.status < 300) {
resolve(request.responseText);
return;
}
if (request.status === 409) {
const error = new Error("conflict");
error.response = { status: request.status, responseText: request.responseText };
reject(error);
return;
}
// Session expired mid-upload: renew once and retry the same body.
if (request.status === 401 && !isRetry && !getters.isShare()) {
void renew()
.then(() => {
if (rejectIfAborted(reject)) {
return;
}
return startRequest(true).then(resolve, reject);
})
.catch(() => {
if (rejectIfAborted(reject)) {
return;
}
let errorMessage = "Upload failed";
try {
const errorData = JSON.parse(request.responseText);
errorMessage = errorData.message || errorMessage;
} catch (_e) {
errorMessage = request.responseText || errorMessage;
}
const error = new Error(errorMessage);
error.status = request.status;
error.response = { status: request.status, responseText: request.responseText };
notify.showError(errorMessage);
reject(error);
});
return;
}
const promise = new Promise((resolve, reject) => {
request.onload = () => {
if (request.status >= 200 && request.status < 300) {
resolve(request.responseText);
} else if (request.status === 409) {
const error = new Error("conflict");
error.response = { status: request.status, responseText: request.responseText };
reject(error);
} else {
let errorMessage = "Upload failed";
try {
const errorData = JSON.parse(request.responseText);
@@ -786,45 +734,29 @@ export function post(
error.response = { status: request.status, responseText: request.responseText };
notify.showError(errorMessage);
reject(error);
};
request.onerror = () => reject(new Error("Network error"));
request.onabort = () => reject(new Error("Upload aborted"));
if (
content instanceof Blob &&
!["http:", "https:"].includes(window.location.protocol)
) {
new Response(content).arrayBuffer()
.then((buffer) => {
if (rejectIfAborted(reject)) {
return;
}
request.send(buffer);
})
.catch((err) => reject(err));
} else {
request.send(content);
}
});
};
const uploadPromise = startRequest(false).then((result) => {
invalidateDirMetadataCache({ source, path: getParentDir(path) });
return result;
});
// Preserve .xhr for pause/abort; re-attach after .then() so callers still see it.
void Object.defineProperty(uploadPromise, "xhr", {
get: () => opState.xhr,
enumerable: true,
});
// Operation-level abort: stops a pending 401 renew+retry as well as the active XHR.
uploadPromise.abort = () => {
opState.aborted = true;
if (opState.xhr && opState.xhr.readyState !== XMLHttpRequest.DONE) {
opState.xhr.abort();
request.onerror = () => reject(new Error("Network error"));
request.onabort = () => reject(new Error("Upload aborted"));
if (
content instanceof Blob &&
!["http:", "https:"].includes(window.location.protocol)
) {
new Response(content).arrayBuffer()
.then(buffer => request.send(buffer))
.catch(err => reject(err));
} else {
request.send(content);
}
};
return uploadPromise;
});
promise.xhr = request;
promise.then(() => {
invalidateDirMetadataCache({ source, path: getParentDir(path) })
}).catch(() => { /* upload failed, so do nothing*/ });
return promise;
} catch (err) {
notify.showError(err.message || "Error posting resource");
// We are returning a promise, so we should return a rejected promise on error.
+17 -3
View File
@@ -1,5 +1,10 @@
import i18n from "@/i18n";
import { state } from "@/store";
import { getters, state } from "@/store";
import { renew } from "@/utils/auth";
function isPublicApiUrl(url) {
return typeof url === "string" && url.includes("public/api/");
}
const defaultRequestTimeoutMs = 5000;
@@ -15,7 +20,7 @@ export function requestTimeoutSignal(ms = defaultRequestTimeoutMs) {
return controller.signal;
}
export async function fetchURL(url, opts, _auth = true) {
export async function fetchURL(url, opts, auth = true) {
opts = opts || {};
opts.headers = opts.headers || {};
@@ -43,7 +48,16 @@ export async function fetchURL(url, opts, _auth = true) {
throw error;
}
// Session JWT renew is handled by utils/auth session keep-alive (not per-request headers).
if (
auth &&
res.headers.get("X-Renew-Token") === "true" &&
!getters.isShare() &&
!isPublicApiUrl(url)
) {
// Cookie is automatically sent, no need to pass JWT from state.
// Skip on public share routes: renew hits /api/auth/renew, which may sit behind proxy basic auth.
await renew();
}
if (res.status < 200 || res.status > 299) {
const error = new Error(await res.text());
+4 -10
View File
@@ -1,13 +1,7 @@
import { getters, state } from "@/store";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import {
VIEW_REFRESH_BEFORE_MS,
msUntilRefresh,
shouldRefreshBeforeExpiry,
} from "@/utils/auth";
/** @deprecated use VIEW_REFRESH_BEFORE_MS — kept as alias for clarity in this module */
const REFRESH_BEFORE_MS = VIEW_REFRESH_BEFORE_MS;
const REFRESH_BEFORE_MS = 10 * 60 * 1000;
let activeScope = "";
let refreshTimer = null;
@@ -102,8 +96,7 @@ function scheduleViewGrantRefresh(source, scope) {
if (!cached?.expiresAt) {
return;
}
// Same pattern as session keep-alive: refresh before expiry, not on request headers.
const delay = msUntilRefresh(cached.expiresAt, REFRESH_BEFORE_MS);
const delay = Math.max(0, cached.expiresAt * 1000 - Date.now() - REFRESH_BEFORE_MS);
if (refreshTimer) {
clearTimeout(refreshTimer);
}
@@ -180,9 +173,10 @@ export async function refreshViewToken(source, existingToken, requestScope = nul
export async function ensureViewToken(source) {
const scope = viewGrantScope(source);
const cached = readCacheForScope(scope);
const now = Date.now();
if (
cached?.viewToken &&
!shouldRefreshBeforeExpiry(cached.expiresAt, REFRESH_BEFORE_MS)
cached.expiresAt * 1000 - now > REFRESH_BEFORE_MS
) {
return cached.viewToken;
}
+2 -18
View File
@@ -9,7 +9,7 @@
type="button"
:key="index"
class="clickable"
:class="{ active: !actionMode && activeButton === index }"
:class="{ active: activeButton === index }"
:disabled="isDisabled"
@click="setActiveButton(index, btn.value)"
>
@@ -38,11 +38,6 @@ export default {
type: String,
default: "",
},
/** When true, clicks always emit button-clicked (no toggle/active selection). */
actionMode: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -58,10 +53,6 @@ export default {
if (this.isDisabled) {
return;
}
if (this.actionMode) {
this.$emit("button-clicked", value);
return;
}
if (value === "type:folder" && this.activeButton !== index) {
this.$emit("disableAll");
}
@@ -92,10 +83,6 @@ export default {
initialActive: {
immediate: true,
handler(newVal) {
if (this.actionMode) {
this.activeButton = null;
return;
}
// Find the button whose value matches initialActive
const initialIndex = this.buttons.findIndex((btn) => btn.value === newVal);
this.activeButton = initialIndex !== -1 ? initialIndex : null; // Set to matching button index or null
@@ -111,8 +98,6 @@ export default {
margin: 1em;
display: flex;
flex-wrap: wrap;
width: 100%;
max-width: 100%;
border: var(--borderWidth) solid var(--surfaceSecondary);
box-shadow: var(--surfaceElevationShadow);
overflow: hidden;
@@ -121,8 +106,7 @@ export default {
button {
cursor: pointer;
flex: 1 1 auto;
min-width: 0;
flex: 1;
height: 3em;
padding: 8px 16px;
border: none;
+2 -30
View File
@@ -286,31 +286,7 @@ export default {
return this.showLimitedOptions && this.selectedCount === 1;
},
permissions() {
const global = getters.globalPermissions();
const items = this.providedItems;
if (items.length > 0) {
const merged = {
view: true,
download: true,
modify: true,
create: false,
delete: true,
};
for (const item of items) {
const source = item.source || state.sources?.current || "";
const perms = getters.sourcePermissions(source);
merged.view = merged.view && perms.view;
merged.download = merged.download && perms.download;
merged.modify = merged.modify && perms.modify;
merged.create = merged.create || perms.create;
merged.delete = merged.delete && perms.delete;
}
return { ...global, ...merged };
}
const source = state.req?.source || state.sources?.current || "";
return { ...global, ...getters.sourcePermissions(source) };
return { ...getters.globalPermissions(), ...getters.sourcePermissions() };
},
/**
* Whether the (+) panel can be opened (create file ops and/or admin-only rows like access rules).
@@ -406,11 +382,7 @@ export default {
return !this.showCreate && this.selectedCount === 1 && !!state.user?.showCopyPath;
},
showOpenParentFolder() {
return (
!this.showCreate &&
this.selectedCount === 1 &&
(this.isSearchActive || this.showLimitedOptions || getters.currentTool()?.component === "AdvancedSearch")
);
return !this.showCreate && this.selectedCount === 1 && (this.isSearchActive || this.showLimitedOptions);
},
showMove() {
if (this.showLimitedOptions) return false;
-9
View File
@@ -156,7 +156,6 @@ import { getters, mutations, state } from "@/store";
import { url } from "@/utils/";
import { globalVars } from "@/utils/constants";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { isTypeAheadSessionActive } from "@/utils/listingTypeAhead.js";
import { utcStartOfDaySecondsFromDateInput } from "@/utils/moment";
const boxes = {
@@ -267,14 +266,6 @@ export default {
// Add keyboard event listener for "/" to activate search
this.handleKeydown = (event) => {
if (
(event.key === '/' || event.key === ' ') &&
isTypeAheadSessionActive() &&
getters.currentView() === 'listingView'
) {
event.preventDefault();
return;
}
if ((event.key === '/' || event.key === ' ') && !state.isSearchActive && !getters.currentPrompt()) {
event.preventDefault();
this.open();
@@ -79,10 +79,8 @@ export default {
isTiff: false,
// Full-frame edge gestures (touch + mouse), zoom scale === 1 only
edgeKind: null, // null | 'horizontal' | 'vertical-dismiss'
edgeGestureActive: false, // true only when this touch started at scale 1
edgeStartX: 0,
edgeStartY: 0,
edgeStartClientX: 0,
edgeDx: 0,
edgeDy: 0,
dragOffsetX: 0,
@@ -100,13 +98,6 @@ export default {
edgeRubberMax: 100,
pinchActive: false,
viewTokenRetried: false,
zoomTapStartX: 0,
zoomTapStartY: 0,
zoomTapMoved: false,
zoomTapToggleTimer: null,
zoomMouseMoved: false,
zoomMouseStartX: 0,
zoomMouseStartY: 0,
};
},
computed: {
@@ -209,7 +200,6 @@ export default {
clearTimeout(this.disabledTimer);
this.disabledTimer = null;
}
this.cancelZoomTapNavToggle();
this.teardownEdgeMouseListeners();
mutations.setNavigationGestureHint({});
window.removeEventListener("resize", this.onResize);
@@ -236,28 +226,6 @@ export default {
}
mutations.setNavigationGestureHint({ kind, commitReady, flashClose });
},
cancelZoomTapNavToggle() {
if (this.zoomTapToggleTimer) {
clearTimeout(this.zoomTapToggleTimer);
this.zoomTapToggleTimer = null;
}
},
scheduleZoomTapNavToggle() {
this.cancelZoomTapNavToggle();
this.zoomTapToggleTimer = setTimeout(() => {
this.zoomTapToggleTimer = null;
if (this.navigationGestureAllowed) {
mutations.toggleNavigationChrome();
}
}, 300);
},
tryToggleNavOnZoomedTap(startX, startY, endX, endY) {
const ax = Math.abs(endX - startX);
const ay = Math.abs(endY - startY);
if (ax < this.edgeHintPx && ay < this.edgeHintPx) {
this.scheduleZoomTapNavToggle();
}
},
loadFullImage() {
if (!this.src) return;
mutations.setLoading("preview-img", true);
@@ -505,7 +473,6 @@ export default {
},
resetEdgeGestureImmediate() {
this.edgeKind = null;
this.edgeGestureActive = false;
this.gestureDecided = false;
this.edgeDx = 0;
this.edgeDy = 0;
@@ -528,9 +495,6 @@ export default {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
if (ax < this.edgeHintPx && ay < this.edgeHintPx) {
if (this.navigationGestureAllowed) {
mutations.toggleNavigationChrome();
}
this.snapBackEdgeGesture();
return;
}
@@ -616,7 +580,6 @@ export default {
this.edgeMouseActive = true;
this.edgeStartX = event.clientX;
this.edgeStartY = event.clientY;
this.edgeStartClientX = event.clientX;
this.edgeDx = 0;
this.edgeDy = 0;
this.edgeKind = null;
@@ -629,18 +592,11 @@ export default {
this.lastX = null;
this.lastY = null;
this.inDrag = true;
this.zoomMouseMoved = false;
this.cancelZoomTapNavToggle();
this.zoomMouseStartX = event.clientX;
this.zoomMouseStartY = event.clientY;
event.preventDefault();
},
mouseMove(event) {
if (event.button !== 0) return;
if (this.scale > 1 && this.inDrag) {
if (event.movementX !== 0 || event.movementY !== 0) {
this.zoomMouseMoved = true;
}
this.doMove(event.movementX, event.movementY);
event.preventDefault();
}
@@ -648,14 +604,6 @@ export default {
mouseUp(event) {
if (event.button !== 0) return;
if (this.scale > 1) {
if (!this.zoomMouseMoved) {
this.tryToggleNavOnZoomedTap(
this.zoomMouseStartX,
this.zoomMouseStartY,
event.clientX,
event.clientY,
);
}
this.inDrag = false;
event.preventDefault();
}
@@ -673,36 +621,22 @@ export default {
}
if (event.targetTouches.length === 1 && this.scale === 1) {
const touch = event.targetTouches[0];
this.edgeGestureActive = true;
this.edgeStartX = touch.pageX;
this.edgeStartY = touch.pageY;
this.edgeStartClientX = touch.clientX;
this.edgeDx = 0;
this.edgeDy = 0;
this.edgeKind = null;
this.gestureDecided = false;
} else if (event.targetTouches.length === 1 && this.scale > 1) {
this.cancelZoomTapNavToggle();
const touch = event.targetTouches[0];
this.zoomTapStartX = touch.pageX;
this.zoomTapStartY = touch.pageY;
this.zoomTapMoved = false;
} else {
this.resetEdgeGestureImmediate();
this.applyImgTransform();
}
// Sequential single-finger taps within 300ms → double-tap zoom
// (@dblclick is unreliable on mobile because touchend preventDefault blocks click synthesis)
if (event.targetTouches.length === 1) {
if (event.targetTouches.length === 2) {
setTimeout(() => {
this.touches = 0;
}, 300);
this.touches++;
if (this.touches > 1) {
this.cancelZoomTapNavToggle();
// zoomAuto may set scale back to 1 before touchend; do not treat that
// touchend as an edge swipe (stale edgeStart would navigate).
this.edgeGestureActive = false;
this.zoomAuto(event);
event.preventDefault();
}
@@ -767,21 +701,15 @@ export default {
}
if (event.targetTouches.length === 1 && this.scale > 1) {
if (this.moveDisabled) return;
const touch = event.targetTouches[0];
const ax = Math.abs(touch.pageX - this.zoomTapStartX);
const ay = Math.abs(touch.pageY - this.zoomTapStartY);
if (ax > 10 || ay > 10) {
this.zoomTapMoved = true;
}
if (this.lastX === null) {
this.lastX = touch.pageX;
this.lastY = touch.pageY;
this.lastX = event.targetTouches[0].pageX;
this.lastY = event.targetTouches[0].pageY;
return;
}
const x = touch.pageX - this.lastX;
const y = touch.pageY - this.lastY;
this.lastX = touch.pageX;
this.lastY = touch.pageY;
const x = event.targetTouches[0].pageX - this.lastX;
const y = event.targetTouches[0].pageY - this.lastY;
this.lastX = event.targetTouches[0].pageX;
this.lastY = event.targetTouches[0].pageY;
this.doMove(x, y);
}
},
@@ -816,28 +744,15 @@ export default {
event.preventDefault();
return;
}
// Only finish edge gestures that started at scale 1. Double-tap zoom-out
// sets scale to 1 on touchstart; without this guard touchend would treat
// stale edgeStart coords as a swipe and navigate.
if (this.scale === 1 && this.edgeGestureActive && event.changedTouches.length > 0) {
if (this.scale === 1 && event.changedTouches.length > 0) {
const t = event.changedTouches[0];
this.edgeDx = t.pageX - this.edgeStartX;
this.edgeDy = t.pageY - this.edgeStartY;
this.finishEdgeGesture();
event.preventDefault();
} else if (this.scale > 1 && event.changedTouches.length > 0) {
if (!this.zoomTapMoved) {
const t = event.changedTouches[0];
this.tryToggleNavOnZoomedTap(
this.zoomTapStartX,
this.zoomTapStartY,
t.pageX,
t.pageY,
);
}
} else if (this.scale > 1) {
event.preventDefault();
}
this.edgeGestureActive = false;
},
},
watch: {
-26
View File
@@ -512,28 +512,6 @@ export default {
opacity: 0.7;
}
.overlay-icon--bottom {
top: auto;
left: auto;
bottom: 0;
right: 0;
width: auto !important;
height: auto !important;
pointer-events: auto;
cursor: pointer;
border: none;
padding: 0;
margin: 0;
background: none;
line-height: 1;
}
.overlay-icon--bottom.clickable:hover {
transform: none;
box-shadow: none !important;
opacity: 1;
}
.file-icons [aria-label^="."] {
opacity: 0.33;
}
@@ -670,10 +648,6 @@ export default {
position: relative;
}
.gallery .image-preview {
border-radius: 0;
}
/* Universal icon styling - uses --icon-font-size variable */
.image-preview i {
padding: 0.1em;
+3 -26
View File
@@ -46,13 +46,6 @@
:isShared="isShared"
:viewToken="viewToken"
/>
<button
v-if="quickDownloadEnabled && galleryView"
type="button"
class="material-symbols overlay-icon overlay-icon--bottom clickable"
aria-label="Download"
@click.stop.prevent="downloadFile"
>file_download</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="text">
@@ -75,7 +68,7 @@
<Icon
@click.stop="downloadFile"
v-if="quickDownloadEnabled && !galleryView"
v-if="quickDownloadEnabled"
:filename="name"
:hasPreview="hasPreview"
mimetype="file_download"
@@ -127,13 +120,6 @@
:isShared="isShared"
:viewToken="viewToken"
/>
<button
v-if="quickDownloadEnabled && galleryView"
type="button"
class="material-symbols overlay-icon overlay-icon--bottom clickable"
aria-label="Download"
@click.stop.prevent="downloadFile"
>file_download</button> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="text">
@@ -255,9 +241,9 @@ export default {
},
quickDownloadEnabled() {
if (getters.isShare()) {
return state.shareInfo?.quickDownload;
return state.shareInfo?.quickDownload && !this.galleryView;
}
return state.user?.quickDownload;
return state.user?.quickDownload && !this.galleryView;
},
quickDownloadPlaceholder() {
if (getters.isShare()) {
@@ -776,15 +762,6 @@ export default {
mutations.resetSelected();
mutations.addSelected(this.index);
mutations.setLastSelectedIndex(this.index);
return;
}
// Single-select: clicking the only selected item again should deselect it.
// ListingView's clickClear only runs when the click target is a child element,
// but list/compact rows are full-width <a> tags so clicks often hit the anchor directly.
mutations.resetSelected();
if (event.currentTarget instanceof HTMLElement) {
event.currentTarget.blur();
}
return;
}
+19 -29
View File
@@ -172,10 +172,8 @@ export default {
);
},
autoShowNavForMediaPreview() {
return getters.previewType() === 'video';
},
isImagePreview() {
return getters.previewType() === 'image';
const pt = getters.previewType();
return pt === 'image' || pt === 'video';
},
/** Close mirrors swipe-down dismiss only (not with prev/next on load or edge hover). */
showCloseNavChrome() {
@@ -485,32 +483,24 @@ export default {
});
},
showInitialNavigation() {
if (!this.enabled) {
return;
}
if (!(this.hasPrevious || this.hasNext || this.autoShowNavForMediaPreview || this.isImagePreview)) {
return;
}
// Show navigation initially for 3 seconds when navigation is set up
if (this.enabled && (this.hasPrevious || this.hasNext || this.autoShowNavForMediaPreview)) {
mutations.setNavigationShow(true);
mutations.clearNavigationTimeout();
if (this.navigationTimeout) {
clearTimeout(this.navigationTimeout);
this.navigationTimeout = null;
}
if (this.isImagePreview) {
mutations.showNavigationChromePersistent();
return;
}
mutations.setNavigationShow(true);
this.navigationTimeout = setTimeout(() => {
if (!this.hoverNav) {
mutations.setNavigationShow(false);
mutations.clearNavigationTimeout();
if (this.navigationTimeout) {
clearTimeout(this.navigationTimeout);
this.navigationTimeout = null;
}
this.navigationTimeout = null;
}, 3000);
mutations.setNavigationTimeout(this.navigationTimeout);
this.navigationTimeout = setTimeout(() => {
if (!this.hoverNav) {
mutations.setNavigationShow(false);
}
this.navigationTimeout = null;
}, 3000);
mutations.setNavigationTimeout(this.navigationTimeout);
}
},
async handleClosePreviewClick() {
if (!(await this.checkForUnsavedChanges())) {
@@ -1019,7 +1009,7 @@ export default {
transform: translateY(-50%);
width: 50px;
height: 50px;
border: var(--borderWidth) solid var(--divider);
border: none;
border-radius: 50%;
background: var(--background);
color: var(--textPrimary);
+22 -55
View File
@@ -49,7 +49,6 @@
type="button"
@click="closeTopPrompt"
class="button button--flat button--grey"
:disabled="deleting"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
@@ -58,7 +57,6 @@
type="button"
@click="submit"
class="button button--flat button--red"
:disabled="deleting"
aria-label="Confirm-Delete"
:title="$t('general.delete')">
{{ $t("general.delete") }}
@@ -181,30 +179,14 @@ export default {
return parts[parts.length - 1] || path;
},
getItemError(item) {
// Find matching failed item by comparing source and path
for (const failedItem of this.failedItems.values()) {
if (failedItem.source === item.source && failedItem.path === item.path) {
return failedItem.message || "Unknown error";
return failedItem.message || 'Unknown error';
}
}
return null;
},
parseBulkDeleteFailures(error) {
if (error?.failed?.length) {
return error.failed;
}
if (!error?.message) {
return null;
}
try {
const parsed = JSON.parse(error.message);
if (Array.isArray(parsed?.failed) && parsed.failed.length > 0) {
return parsed.failed;
}
} catch (_e) {
// Not JSON — fall through
}
return null;
},
getPreviewUrl(source, path, modified, type) {
if (!source || !path) return null;
@@ -239,9 +221,9 @@ export default {
try {
// Extract source and path from items (ignore previewUrl)
const itemsForDelete = this.itemsToDelete.map((item) => ({
const itemsForDelete = this.itemsToDelete.map(item => ({
source: item.source,
path: item.path,
path: item.path
}));
if (itemsForDelete.length === 0) {
@@ -255,12 +237,15 @@ export default {
? await resourcesApi.bulkDeletePublic(itemsForDelete)
: await resourcesApi.bulkDelete(itemsForDelete);
const failed = Array.isArray(response?.failed) ? response.failed : [];
const succeeded = Array.isArray(response?.succeeded) ? response.succeeded : [];
this.failedItems = failed;
// Store failed items directly from response
if (response.failed && response.failed.length > 0) {
this.failedItems = response.failed;
} else {
this.failedItems = [];
}
const succeededCount = succeeded.length;
const failedCount = failed.length;
const succeededCount = response.succeeded ? response.succeeded.length : 0;
const failedCount = response.failed ? response.failed.length : 0;
if (failedCount === 0) {
// All succeeded - close prompt and handle navigation
@@ -269,8 +254,8 @@ export default {
if (this.items && this.items.length > 0) {
eventBus.emit("itemsDeleted", {
succeeded,
failed,
succeeded: response.succeeded || [],
failed: []
});
}
mutations.closeTopPrompt();
@@ -319,23 +304,13 @@ export default {
// Emit event with partial results if items were passed as props
if (this.items && this.items.length > 0) {
eventBus.emit("itemsDeleted", {
succeeded,
failed,
succeeded: response.succeeded || [],
failed: response.failed || []
});
}
if (failedCount > 0) {
notify.showError(
failed[0]?.message || this.$t("prompts.operationFailed")
);
}
} else {
// All failed
buttons.done("delete");
if (failedCount > 0) {
notify.showError(
failed[0]?.message || this.$t("prompts.operationFailed")
);
}
}
this.deleting = false;
@@ -343,20 +318,12 @@ export default {
buttons.done("delete");
this.deleting = false;
console.error(e);
const parsedFailures = this.parseBulkDeleteFailures(e);
if (parsedFailures) {
this.failedItems = parsedFailures;
notify.showError(
parsedFailures[0]?.message || this.$t("prompts.operationFailed")
);
} else {
this.failedItems = this.itemsToDelete.map((item) => ({
source: item.source,
path: item.path,
message: e.message || "Delete failed",
}));
notify.showError(e.message || this.$t("prompts.operationFailed"));
}
// On network/API errors, show error for all items
this.failedItems = this.itemsToDelete.map(item => ({
source: item.source,
path: item.path,
message: e.message || 'Delete failed'
}));
}
},
},
+2 -8
View File
@@ -30,7 +30,6 @@
type="button"
v-if="canCreateFolder && showNewDirInput"
class="button button--flat"
:disabled="isLoading"
@click="cancelNewDir"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
@@ -41,7 +40,6 @@
type="button"
v-if="canCreateFolder && !showNewDirInput"
class="button button--flat"
:disabled="isLoading"
@click="createNewDir"
:aria-label="$t('files.newFolder')"
:title="$t('files.newFolder')"
@@ -52,8 +50,7 @@
v-model.trim="newDirName" :placeholder="$t('files.newFolderMessage')" @keydown.enter="handleEnter" />
<button
type="button"
v-else
:disabled="destContainsSrc || isLoading"
v-else :disabled="destContainsSrc"
class="button button--flat"
@click="performOperation"
:aria-label="operation === 'move' ? $t('general.move') : $t('general.copy')"
@@ -66,7 +63,7 @@
v-if="showNewDirInput"
class="button button--flat"
@click="createDirectory"
:disabled="isLoading || !newDirName || !isDirNameValid"
:disabled="!newDirName || !isDirNameValid"
>
{{ $t("general.create") }}
</button>
@@ -277,9 +274,6 @@ export default {
},
performOperation: async function (event) {
event.preventDefault();
if (this.isLoading) {
return;
}
this.isLoading = true; // Show loading spinner
try {
// Ensure destination source is set
+42 -43
View File
@@ -40,12 +40,39 @@
<div class="upload-prompt-container">
<i v-if="files.length === 0" class="material-symbols">cloud_upload</i>
<p v-if="files.length === 0">{{ $t("prompts.dragAndDrop") }}</p>
<ButtonGroup
class="upload-action-buttons"
:buttons="uploadActionButtons"
action-mode
@button-clicked="onUploadAction"
/>
<div class="button-group">
<button
type="button"
@click="triggerFilePicker"
class="button button--flat"
>
{{ $t("general.file") }}
</button>
<button
type="button"
style="margin-left: 1em"
@click="triggerFolderPicker"
class="button button--flat"
>
{{ $t("general.folder") }}
</button>
<button
type="button"
style="margin-left: 1em"
@click="triggerCameraPicker"
class="button button--flat"
>
{{ $t("general.camera") }}
</button>
<button
type="button"
style="margin-left: 1em"
@click="triggerVideoPicker"
class="button button--flat"
>
{{ $t("general.video") }}
</button>
</div>
</div>
</div>
</div>
@@ -192,7 +219,6 @@ import { notify } from "@/notify";
import ProgressBar from "@/components/ProgressBar.vue";
import SettingsItem from "@/components/settings/SettingsItem.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import ButtonGroup from "@/components/ButtonGroup.vue";
import i18n from "@/i18n";
export default {
@@ -201,7 +227,6 @@ export default {
ProgressBar,
SettingsItem,
ToggleSwitch,
ButtonGroup,
},
props: {
initialItems: {
@@ -492,32 +517,6 @@ export default {
if (videoInput.value) videoInput.value.click();
};
const uploadActionButtons = computed(() => [
{ label: i18n.global.t("general.file"), value: "file" },
{ label: i18n.global.t("general.folder"), value: "folder" },
{ label: i18n.global.t("general.camera"), value: "camera" },
{ label: i18n.global.t("general.video"), value: "video" },
]);
const onUploadAction = (value) => {
switch (value) {
case "file":
triggerFilePicker();
break;
case "folder":
triggerFolderPicker();
break;
case "camera":
triggerCameraPicker();
break;
case "video":
triggerVideoPicker();
break;
default:
break;
}
};
const onFilePicked = (event) => {
const pickedFiles = event.target.files;
if (pickedFiles.length > 0) {
@@ -668,8 +667,6 @@ export default {
triggerFolderPicker,
triggerCameraPicker,
triggerVideoPicker,
uploadActionButtons,
onUploadAction,
onFilePicked,
onFolderPicked,
fileInput,
@@ -709,6 +706,15 @@ export default {
</script>
<style scoped>
.button-group {
display: flex;
justify-content: center;
}
:deep(.settings-group-title) {
margin-top: 0;
}
.upload-prompt {
text-align: center;
padding: 2em;
@@ -728,13 +734,6 @@ export default {
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.upload-action-buttons {
margin: 0.5em 0 0;
}
.upload-prompt i {
-1
View File
@@ -170,7 +170,6 @@ body.rtl .listing-items {
height: 100%;
aspect-ratio: 1;
transition: all 0.25s ease-out;
position: relative;
}
.listing-items.gallery .listing-item .text {
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "مرحبًا بك في متصفح الملفات الكمي!",
"halloweenTitle": "عيد هالوين سعيد!",
"halloweenBody": "تم تطبيق سمة الهالوين. يمكنك تعطيله إذا لم يعجبك، أو تعطيله بشكل دائم في إعدادات الملف الشخصي عن طريق تحديد أي سمة أخرى.",
"firstLoadBody": "تم إنشاء قاعدة بيانات جديدة عند بدء التشغيل. إذا لم يكن هذا هو ما كنت تتوقعه، يرجى التحقق مرة أخرى من ملف التكوين الخاص بك لمسار 'server.database' والتأكد من وجوده. في docker، يمكن أن يكون مسار قاعدة البيانات \"./data/filebrowser.sqlite\" أو \"./filebrowser.sqlite\".",
"firstLoadBody": "تم إنشاء قاعدة بيانات جديدة عند بدء التشغيل. إذا لم يكن هذا هو ما كنت تتوقعه، يرجى التحقق مرة أخرى من ملف التكوين الخاص بك لمسار 'server.database' والتأكد من وجوده. في docker، يمكن أن يكون مسار قاعدة البيانات \"./data/database.db\" أو \"./database.db\".",
"selectPath": "تحديد المسار",
"renameSuccess": "تمت إعادة تسمية العنصر بنجاح!",
"newFileSuccess": "تم إنشاء الملف بنجاح!",
+1 -1
View File
@@ -450,7 +450,7 @@
"token": "Токен",
"onlyOfficeId": "OnlyOffice ID",
"firstLoadTitle": "Добре дошли във FileBrowser Quantum!",
"firstLoadBody": "Нова база данни беше създадена при стартиране. Ако това не е очакваното, моля, проверете конфигурационния файл за `server.database` път и се уверете, че съществува. В docker, пътят на базата данни може да бъде `./data/filebrowser.sqlite` или `./filebrowser.sqlite`.",
"firstLoadBody": "Нова база данни беше създадена при стартиране. Ако това не е очакваното, моля, проверете конфигурационния файл за `server.database` път и се уверете, че съществува. В docker, пътят на базата данни може да бъде `./data/database.db` или `./database.db`.",
"halloweenTitle": "Честит Хелоуин!",
"halloweenBody": "Темата за Хелоуин беше приложена. Можете да я изключите, ако не ви харесва, или да я изключите завинаги в настройките на профила, като изберете друга тема.",
"moveFailed": "Преместването не беше успешно",
+1 -1
View File
@@ -273,7 +273,7 @@
"firstLoadTitle": "Vítejte v aplikaci FileBrowser Quantum!",
"halloweenTitle": "Šťastný Halloween!",
"halloweenBody": "Bylo použito téma Halloween. Pokud se vám nelíbí, můžete jej vypnout nebo trvale zakázat v nastavení profilu výběrem jiného motivu.",
"firstLoadBody": "Při spuštění byla vytvořena nová databáze. Pokud to není to, co jste očekávali, překontrolujte prosím konfigurační soubor pro cestu `server.database` a ujistěte se, že existuje. V dockeru může být cesta k databázi `./data/filebrowser.sqlite` nebo `./filebrowser.sqlite`.",
"firstLoadBody": "Při spuštění byla vytvořena nová databáze. Pokud to není to, co jste očekávali, překontrolujte prosím konfigurační soubor pro cestu `server.database` a ujistěte se, že existuje. V dockeru může být cesta k databázi `./data/database.db` nebo `./database.db`.",
"selectPath": "Vybrat cestu",
"renameSuccess": "Položka byla úspěšně přejmenována!",
"newFileSuccess": "Soubor byl úspěšně vytvořen!",
+2 -2
View File
@@ -21,7 +21,7 @@
"discardAndExit": "Verwerfen und Beenden",
"saveAndExit": "Speichern und Beenden",
"keepEditing": "Weiter bearbeiten",
"closeAnyway": "Trotzdem schließen",
"closeAnyway": "Jedenfalls schließen",
"watchFile": "Datei beobachten",
"showMore": "Erweiterte Optionen anzeigen",
"showLess": "Erweiterte Optionen ausblenden",
@@ -203,7 +203,7 @@
"firstLoadTitle": "Willkommen bei FileBrowser Quantum!",
"halloweenTitle": "Fröhliches Halloween!",
"halloweenBody": "Das Halloween-Thema wurde angewendet. Sie können es deaktivieren, wenn es Ihnen nicht gefällt, oder es in den Profileinstellungen dauerhaft deaktivieren, indem Sie ein anderes Thema auswählen.",
"firstLoadBody": "Beim Starten wurde eine neue Datenbank erstellt. Wenn dies nicht das ist, was Sie erwartet haben, überprüfen Sie bitte Ihre Konfigurationsdatei auf den Pfad `server.database` und stellen Sie sicher, dass er existiert. Bei Docker kann der Datenbankpfad `./data/filebrowser.sqlite` oder `./filebrowser.sqlite` sein.",
"firstLoadBody": "Beim Starten wurde eine neue Datenbank erstellt. Wenn dies nicht das ist, was Sie erwartet haben, überprüfen Sie bitte Ihre Konfigurationsdatei auf den Pfad `server.database` und stellen Sie sicher, dass er existiert. Bei Docker kann der Datenbankpfad `./data/database.db` oder `./database.db` sein.",
"selectPath": "Pfad auswählen",
"renameSuccess": "Artikel erfolgreich umbenannt!",
"newFileSuccess": "Datei erfolgreich erstellt!",
+1 -1
View File
@@ -203,7 +203,7 @@
"firstLoadTitle": "Καλώς ήρθατε στο FileBrowser Quantum!",
"halloweenTitle": "Καλές Απόκριες!",
"halloweenBody": "Το θέμα των αποκριών έχει εφαρμοστεί. Μπορείτε να το απενεργοποιήσετε αν δεν σας αρέσει ή να το απενεργοποιήσετε μόνιμα στις ρυθμίσεις προφίλ επιλέγοντας οποιοδήποτε άλλο θέμα.",
"firstLoadBody": "Κατά την εκκίνηση δημιουργήθηκε μια νέα βάση δεδομένων. Αν αυτό δεν είναι αυτό που περιμένατε, ελέγξτε ξανά το αρχείο ρυθμίσεων για τη διαδρομή `server.database` και βεβαιωθείτε ότι υπάρχει. Στο docker, η διαδρομή της βάσης δεδομένων μπορεί να είναι `./data/filebrowser.sqlite` ή `./filebrowser.sqlite`.",
"firstLoadBody": "Κατά την εκκίνηση δημιουργήθηκε μια νέα βάση δεδομένων. Αν αυτό δεν είναι αυτό που περιμένατε, ελέγξτε ξανά το αρχείο ρυθμίσεων για τη διαδρομή `server.database` και βεβαιωθείτε ότι υπάρχει. Στο docker, η διαδρομή της βάσης δεδομένων μπορεί να είναι `./data/database.db` ή `./database.db`.",
"selectPath": "Επιλέξτε διαδρομή",
"renameSuccess": "Το στοιχείο μετονομάστηκε επιτυχώς!",
"newFileSuccess": "Το αρχείο δημιουργήθηκε με επιτυχία!",
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more