mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 21:22:00 -05:00
Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f71239e14 | ||
|
|
8fd7da09a5 | ||
|
|
4b6611afaa | ||
|
|
3f484f77d0 | ||
|
|
9275b34622 | ||
|
|
94481b83b6 | ||
|
|
d61720817e | ||
|
|
d30fbb1afd | ||
|
|
1a7a94d301 | ||
|
|
d809654e3c | ||
|
|
14350c31a5 | ||
|
|
3d06ee685a | ||
|
|
7feb44caf9 | ||
|
|
53ed0095e1 | ||
|
|
2d49cb4508 | ||
|
|
458c25a6c5 | ||
|
|
2910415458 | ||
|
|
795b12ee2a | ||
|
|
5b5b10cde6 | ||
|
|
bb7b6447ae | ||
|
|
16d1b628a7 | ||
|
|
330f9c8db8 | ||
|
|
c34c8d0a0a | ||
|
|
f314695267 | ||
|
|
d4cd8c3300 | ||
|
|
e04bfc3182 | ||
|
|
02ec827c72 | ||
|
|
0953ce40dd | ||
|
|
72b9d6d14e | ||
|
|
9a941507f4 | ||
|
|
b0d1af05df | ||
|
|
8b26cfd299 | ||
|
|
7c05cd66ea | ||
|
|
3483975b90 | ||
|
|
f76c11333b | ||
|
|
78c56dac8d | ||
|
|
8ac2f1b3af | ||
|
|
12da90a7a7 | ||
|
|
d14267994a | ||
|
|
ccafe483d8 | ||
|
|
818d432cc8 | ||
|
|
f1ae627854 | ||
|
|
b4d23ab1f6 | ||
|
|
81e67cfcee | ||
|
|
eedc6f0f92 | ||
|
|
27854cf738 | ||
|
|
e753dd5966 | ||
|
|
c630c002dc | ||
|
|
d141c8fa7d | ||
|
|
5c3e7e6560 | ||
|
|
304bef7909 | ||
|
|
050e89e44a | ||
|
|
8dbc69fa73 | ||
|
|
b52c25582b | ||
|
|
edcd580f0e | ||
|
|
2a1cc76ece | ||
|
|
30fa7279e6 | ||
|
|
c7cfe66797 | ||
|
|
4d76a8fdb5 | ||
|
|
9980dd4d22 | ||
|
|
dd169dccbd | ||
|
|
ddb72de88c | ||
|
|
d607c61e56 | ||
|
|
24534161b9 | ||
|
|
86bac02f66 | ||
|
|
35ae1b3869 | ||
|
|
a6a3fd1268 | ||
|
|
a58dc776c3 | ||
|
|
0324f37d91 | ||
|
|
c6625657a6 | ||
|
|
31831a74e5 | ||
|
|
7684804d94 | ||
|
|
e6039e18f3 | ||
|
|
6a11094c05 | ||
|
|
051d6672ae | ||
|
|
8e716fa2c1 | ||
|
|
833e180fad | ||
|
|
c38db32ee7 | ||
|
+5 |
b08c6c2389 | ||
|
|
f743ff51d0 | ||
|
|
e8f25a1673 | ||
|
|
59cd9ef7f0 | ||
|
|
4e6f044407 | ||
|
|
3167aba55c | ||
|
|
96c11c7373 | ||
|
|
682b9257f5 | ||
|
|
e26e904962 | ||
|
|
b326c22854 | ||
|
|
c07ddc4157 | ||
|
|
949e2687fc | ||
|
|
fa5d5d5f8e | ||
|
|
3b529777a8 | ||
|
|
88e37c0667 | ||
|
|
afd84ea35a | ||
|
|
cdbc7a35f7 | ||
|
|
a18f268547 | ||
|
|
28edf9ce0e | ||
|
|
e99b22349d | ||
|
|
9775cc03fd | ||
|
|
18333e21fd | ||
|
|
ad3d184811 | ||
|
|
d47c2d9c0d | ||
|
|
91b67a395e | ||
|
|
04e4324981 | ||
|
|
c1c0f7a25b | ||
|
|
df9e7571a1 | ||
|
|
94d6045a22 | ||
|
|
44edfcafa1 | ||
|
|
be2e78f729 | ||
|
|
c22fc59035 | ||
|
|
29b631657f | ||
|
|
ad82de28e8 | ||
|
|
fea07ef202 | ||
|
|
0481052308 | ||
|
|
2f024c3fee | ||
|
|
87d241d166 | ||
|
|
092c97f9ae |
No files matched your search
@@ -5,7 +5,7 @@
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "22"
|
||||
"version": "20"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
|
||||
@@ -5,9 +5,6 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright
|
||||
@@ -20,8 +17,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
|
||||
@@ -33,8 +33,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Promote Beta to Stable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to promote (format: 0.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
promote-beta-to-stable:
|
||||
name: Promote Beta to Stable
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }} # Uses Personal Access Token for branch operations
|
||||
|
||||
- name: Validate version format
|
||||
id: validate_version
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "❌ ERROR: Version must be in '0.0.0' format."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Version format validated: $VERSION"
|
||||
|
||||
- name: Check if beta branch exists
|
||||
id: check_beta_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
|
||||
if ! git ls-remote --exit-code origin "$BETA_BRANCH"; then
|
||||
echo "❌ ERROR: Beta branch '$BETA_BRANCH' does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Beta branch '$BETA_BRANCH' exists."
|
||||
|
||||
- name: Check if stable branch already exists
|
||||
id: check_stable_branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
if git ls-remote --exit-code origin "$STABLE_BRANCH"; then
|
||||
echo "❌ ERROR: Stable branch '$STABLE_BRANCH' already exists!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Stable branch '$STABLE_BRANCH' does not exist."
|
||||
|
||||
- name: Create and push new stable branch
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
# Fetch latest changes
|
||||
git fetch origin "$BETA_BRANCH"
|
||||
|
||||
# Create new stable branch from beta branch
|
||||
git checkout -b "$STABLE_BRANCH" origin/"$BETA_BRANCH"
|
||||
|
||||
# Push new stable branch to remote
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully created stable branch '$STABLE_BRANCH' from '$BETA_BRANCH'."
|
||||
|
||||
- name: Merge beta into stable
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
BETA_BRANCH="beta/v$VERSION"
|
||||
STABLE_BRANCH="stable/v$VERSION"
|
||||
|
||||
# Checkout stable branch
|
||||
git checkout "$STABLE_BRANCH"
|
||||
|
||||
# Merge beta into stable
|
||||
git merge --no-ff "origin/$BETA_BRANCH" -m "Merge $BETA_BRANCH into $STABLE_BRANCH"
|
||||
|
||||
# Push merge changes
|
||||
git push origin "$STABLE_BRANCH"
|
||||
|
||||
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
|
||||
@@ -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:
|
||||
@@ -38,8 +41,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run lint
|
||||
test-frontend:
|
||||
@@ -47,8 +48,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run test
|
||||
check-translations:
|
||||
@@ -56,8 +55,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run i18n:check
|
||||
check-docs:
|
||||
@@ -74,7 +71,5 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run icons:check
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,66 @@
|
||||
name: stable release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "stable/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright - regular
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- uses: actions/setup-node@v4
|
||||
- working-directory: frontend
|
||||
run: npm i && npm run build
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- working-directory: backend
|
||||
run: go build -o filebrowser .
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
create_release_tag:
|
||||
needs: [ test_playwright ]
|
||||
name: Create Release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
- name: Extract branch name
|
||||
shell: bash
|
||||
run: |
|
||||
original_branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}
|
||||
echo "branch_name=$transformed_branch" >> $GITHUB_OUTPUT
|
||||
tag_name=$(echo "$original_branch" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-stable
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
id: extract_branch
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false
|
||||
make_latest: true
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
@@ -17,8 +17,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: frontend/.nvmrc
|
||||
- name: Install dependencies and build frontend
|
||||
run: npm i && npm run build
|
||||
working-directory: frontend
|
||||
|
||||
@@ -2,95 +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.5
|
||||
|
||||
**New Features**:
|
||||
- Users can set default view mode and thumbnail size from Profile settings (Listing options). Admins can set the same defaults for existing users from the user edit prompt. Values persist to the account and apply on new browsers or devices; they do not override view preferences already saved locally ([#2884](https://github.com/gtsteffaniak/filebrowser/issues/2884)).
|
||||
- Admins can edit all profile preference defaults for existing users from the user management panel (listing, preview, sidebar, theme, and related options), matching what was previously available only for new users and global user defaults.
|
||||
|
||||
**Security**:
|
||||
- [Moderate] public metadata api returns file content to anonymous share visitors, ignoring the share's download limit and file-viewer setting (GHSA-55mw-cwg7-m8f5) -- thanks @kta1kri
|
||||
|
||||
**Bugfixes**:
|
||||
- Fixed infinite loading spinner on `/` and `/login` after CSP security hardening ([#2886](https://github.com/gtsteffaniak/filebrowser/issues/2886), [#2890](https://github.com/gtsteffaniak/filebrowser/issues/2890))
|
||||
- fixed darkmode styling bug where background wouldn't respect configured settings when not logged in
|
||||
- fixed styling bug where padding was missing from right side of listing view.
|
||||
- tilde paths for sources were not properly expanded. (eg "~" for home)
|
||||
- fixed tooltip not showing up for some circumstances on mobile, improved consistency.
|
||||
- a few styling fixes for inconsistencies (#2908)
|
||||
|
||||
## v2.0.4
|
||||
|
||||
**Bugfixes**:
|
||||
- OnlyOffice editor failed to load after the CSP security fix; allow configured `integrations.office.url` and `integrations.office.internalUrl` origins in `script-src` ([#2874](https://github.com/gtsteffaniak/filebrowser/issues/2874)).
|
||||
|
||||
## v2.0.3
|
||||
|
||||
**Security**:
|
||||
- [High] Stored XSS via HTML preview: strip `<script>` from sanitized srcdoc, remove `allow-same-origin` from the preview iframe sandbox, set HttpOnly on the session cookie, and add a nonce-based `script-src` CSP on the SPA shell (GHSA-vvm6-jwrf-hgmg) -- thanks @qrn12580
|
||||
- [Moderate] Non-admin users could PATCH privileged fields on their own account (scopes, permissions, etc.), allowing privilege escalation; restores v1 non-admin field guard on PATCH /api/users (GHSA-p5cc-4p84-c4m2) -- thanks @Chri6s
|
||||
|
||||
**New Features**:
|
||||
- Added option to globally disable the "Install App" message via `frontend.disablePWAInstall`
|
||||
- PWA improvements for installed mobile apps: dedicated maskable icons (192/512), manifest and splash colors that follow the instance default theme, runtime `theme-color` sync on dark-mode toggle, and edge-to-edge safe-area layout for notched devices (#2625) (#2869) -- thanks @APatenaude
|
||||
|
||||
**Bugfixes**:
|
||||
- Fixed Fuji `.raf` thumbnail preview by extracting the camera-embedded JPEG from the RAF header (regression for files where TIFF-based raw extraction does not apply).
|
||||
- Image previews for unsupported decodable formats now return HTTP 415 immediately instead of attempting JPEG decode and returning HTTP 500.
|
||||
- OIDC: preserve the verified ID-token user identifier when falling back to the UserInfo endpoint for missing groups, so login no longer fails with HTTP 500 when UserInfo returns groups but omits the configured identifier.
|
||||
- LDAP: restore `memberOf` as the default `groupsClaim` when unset, fixing group-based admin and login authorization for existing LDAP configs after the v2.0.2 default changed to `groups`.
|
||||
- "Install app" message reappears after being cleared on device.
|
||||
- Restored upload chunk size `0` to disable chunking as documented ([#2202](https://github.com/gtsteffaniak/filebrowser/issues/2202)).
|
||||
- Fixed iOS 26 / WebKit multi-chunk upload stall by isolating chunk connections and returning partial chunk JSON responses ([#2734](https://github.com/gtsteffaniak/filebrowser/issues/2734)).
|
||||
- Sidebar folder links with custom names, icons, or styles no longer disappear after restart; multiple shortcuts to different folders on the same source are preserved ([#2809](https://github.com/gtsteffaniak/filebrowser/issues/2809)).
|
||||
- Adding a source to a user via scopes now auto-adds a matching sidebar link; removing a source keeps the link (shown disabled) so users can delete it manually.
|
||||
- A password reset via CLI returns user to a password method user.
|
||||
- Authentication login methods are consistently enforced without modifying existing account settings.
|
||||
|
||||
## v2.0.2
|
||||
|
||||
**Bugfixes**:
|
||||
- OIDC: `groupsClaim` value is always included in requested scopes and falls back to the UserInfo endpoint when the ID token omits the groups claim.
|
||||
- Windows: fix backslash duplication in navigation URLs, folder sizes showing 4 KB, and download/preview failures (#2815, #2816)
|
||||
- Wrong extension in the 'new database was created popup (#2817) (#2824)
|
||||
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
|
||||
- OnlyOffice is inaccessible on password-protected shares (#2811)
|
||||
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
|
||||
|
||||
## v2.0.1
|
||||
|
||||
**Security**:
|
||||
- [High] Symlink following on read paths no longer escapes source or user/share scope (GHSA-mgqf-5mf5-prfj) -- thanks @je-lv @KasperBuilds
|
||||
- [Moderate] Absolute paths in API path/file parameters could bypass user scope and read files outside the source mount (GHSA-rqqq-wv83-rp74) -- thanks @Wei-Leo
|
||||
- [Moderate] Logout did not invalidate session JWTs with equivalent Base64URL spellings (GHSA-8m35-wcjh-95q7) -- thanks @corbanvilla @soh3e @dderpym (This vulnerability was discovered as part of a U.C. Berkeley security research project by: Corban Villa, Sohee Kim, and Austin Chu)
|
||||
- [Moderate] Stored XSS via unsanitized DOCX hyperlink in DocViewer (GHSA-9wm6-jcjh-3m8c) -- thanks @karen93shieh @je-lv @EclipsSec
|
||||
- [Moderate] Revoked JWTs could still authenticate on public-share and withOrWithoutUser routes until natural expiry, bypassing logout and Api-permission revocation on that surface (GHSA-4wmj-rq3c-m65v) -- thanks @hypnguyen1209
|
||||
|
||||
**Notes**:
|
||||
- GroupMap `SyncUserGroups` fix so JWT/OIDC/LDAP group memberships survive restart (#2742).
|
||||
- Session renew is handled by client keep-alive. removed per-request `X-Renew-Token` header handling.
|
||||
- Improvements to document thumbnail generation performance.
|
||||
- behavior changes for typing to select files in listing view.
|
||||
- next/previous buttons don't hide automatically on photos (#2767)
|
||||
- added more actions available in advanced search (#2776)
|
||||
|
||||
**Bugfixes**:
|
||||
- GroupMap mutations (`SyncUserGroups`, add/remove group members) are write-through to SQL so JWT/OIDC/LDAP group memberships survive restart (#2742).
|
||||
- `defaultEnabled` behavior change/fix so defaultEnabled means its always added to users on startup and login. (updated docs too)
|
||||
- Long uploads/downloads no longer lose the session mid-transfer: proactive session JWT keep-alive renews before expiry (#2638).
|
||||
- Disable auto logout/session expiry if there are active file transfers going (#2638)
|
||||
- upload options get cut off on mobile (#2685)
|
||||
- Uploading image: image gets cut-off (#2765)
|
||||
- Process aborts (not just 500) during PDF thumbnail generation (#2763)
|
||||
- PDF preview generation prevents file uploads from completing (#2752)
|
||||
- Brings back double-tap to zoom images that was mistakenly removed.
|
||||
- multiple embedded subtitles with same language bug (#2756)
|
||||
- gallery view download button missing (#2767)
|
||||
- fixed HEIC rotation regression from v1.5.x
|
||||
- fixed raw image preview regression from v1.5.x
|
||||
- html viewer takes full height
|
||||
- Members without download permission receive 403 when opening text-based files despite OnlyOffice preview being enabled (#2777)
|
||||
|
||||
## v2.0.0
|
||||
|
||||
This version represents the most significant change to date. It **requires** both a database migration and config structural changes. See the [migration guide](https://filebrowserquantum.com/en/docs/getting-started/v2/migration/) for step-by-step upgrade instructions, [About v2.0.0](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) for a full summary, and the [config migration tool](https://filebrowserquantum.com/en/docs/getting-started/v2/config-migration/) to convert legacy config.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
|
||||
- **Go 1.25+** (see `backend/go.mod`)
|
||||
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
|
||||
- **Docker** (optional, for containerized development)
|
||||
- **Git** especially important on Windows -- needed for bash support.
|
||||
- **Git** especially improtant on windows -- needed for bash support.
|
||||
|
||||
### Optional Tools
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-3
@@ -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}' \
|
||||
@@ -14,11 +13,11 @@ RUN go build -tags analytics,mupdf,musl -ldflags="-w -s \
|
||||
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
|
||||
COPY ./frontend/package.json ./frontend/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN cd frontend && npm ci --maxsockets 1
|
||||
RUN cd frontend && npm i --maxsockets 1
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
@@ -13,4 +13,4 @@ RUN mkdir -p ./internal/web/dist
|
||||
COPY [ "./backend/internal/web/embed/", "./internal/web/dist/"]
|
||||
|
||||
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
|
||||
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
|
||||
@@ -11,11 +11,11 @@ RUN go build -tags analytics -ldflags="-w -s \
|
||||
|
||||
FROM node:jod-slim AS nbuild
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package.json ./frontend/package-lock.json ./frontend/
|
||||
COPY ./frontend/package.json ./frontend/
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
|
||||
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
RUN cd frontend && npm ci
|
||||
RUN cd frontend && npm i
|
||||
COPY ./frontend/ ./frontend/
|
||||
WORKDIR /build/frontend
|
||||
RUN npm run build:docker
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -10,7 +9,7 @@ import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig<PlaywrightFixtureOptions>({
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/screenshots-setup.ts",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/screenshots",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+4
-10
@@ -115,7 +115,7 @@ func setUser(username, password string, asAdmin bool) error {
|
||||
return nil
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
logger.Infof("Converting user %s from %s to password login method", username, user.LoginMethod)
|
||||
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
|
||||
}
|
||||
user.TOTPSecret = ""
|
||||
user.TOTPNonce = ""
|
||||
@@ -139,9 +139,6 @@ func promoteUser(username string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("user %s not found", username)
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
return fmt.Errorf("user %s cannot be promoted: only password-login users can be granted admin via CLI", username)
|
||||
}
|
||||
if user.Permissions.Admin {
|
||||
fmt.Printf("user %s is already an admin\n", username)
|
||||
return nil
|
||||
@@ -241,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")
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupCLIUserTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.InitializeCLI("../../../_docker/src/noauth/backend/config.yaml")
|
||||
settings.Env.IsPlaywright = true
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
store, _, err := state.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app.MustWireServices(store)
|
||||
t.Cleanup(func() { _ = state.Close() })
|
||||
}
|
||||
|
||||
func TestSetUserConvertsOIDCToPassword(t *testing.T) {
|
||||
setupCLIUserTest(t)
|
||||
|
||||
oidcUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(oidcUser, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := setUser("graham", "newpassword", false); err != nil {
|
||||
t.Fatalf("setUser: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := state.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.LoginMethod != users.LoginMethodPassword {
|
||||
t.Fatalf("loginMethod = %q, want password", loaded.LoginMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteUserRejectsNonPasswordLoginMethod(t *testing.T) {
|
||||
setupCLIUserTest(t)
|
||||
|
||||
oidcUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(oidcUser, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := promoteUser("graham")
|
||||
if err == nil {
|
||||
t.Fatal("promoteUser: expected error for oidc user")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "password-login") {
|
||||
t.Fatalf("promoteUser err = %q, want password-login restriction", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteUserAllowsPasswordLoginMethod(t *testing.T) {
|
||||
setupCLIUserTest(t)
|
||||
|
||||
passwordUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(passwordUser, "secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := promoteUser("graham"); err != nil {
|
||||
t.Fatalf("promoteUser: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := state.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !loaded.Permissions.Admin {
|
||||
t.Fatal("expected admin after promote")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -210,14 +210,14 @@ func runCLI() (keepGoing bool, dbExists bool) {
|
||||
switch {
|
||||
case cmd == "" || cmd == "run":
|
||||
requireExistingConfig(configPath)
|
||||
dbExists = initializeDatabase(configPath, false)
|
||||
dbExists = initializeDatabase(configPath)
|
||||
return true, dbExists
|
||||
case cmd == "version" || cmd == "setup":
|
||||
parser.FatalIfErrorf(ctx.Run(&rootCLI))
|
||||
return false, false
|
||||
case cmd == "set rule" || cmd == "set" || strings.HasPrefix(cmd, "user set") || strings.HasPrefix(cmd, "user promote"):
|
||||
requireExistingConfig(configPath)
|
||||
dbExists = initializeDatabase(configPath, true)
|
||||
dbExists = initializeDatabase(configPath)
|
||||
parser.FatalIfErrorf(ctx.Run(&rootCLI))
|
||||
return false, dbExists
|
||||
default:
|
||||
|
||||
+2
-6
@@ -29,12 +29,8 @@ import (
|
||||
|
||||
var runtimeApp *app.App
|
||||
|
||||
func initializeDatabase(configFile string, cliMode bool) bool {
|
||||
if cliMode {
|
||||
settings.InitializeCLI(configFile)
|
||||
} else {
|
||||
settings.Initialize(configFile)
|
||||
}
|
||||
func initializeDatabase(configFile string) bool {
|
||||
settings.Initialize(configFile)
|
||||
|
||||
if err := validateDatabasePaths(); err != nil {
|
||||
logger.Fatalf("%v", err)
|
||||
|
||||
+68
-19
@@ -81,8 +81,7 @@ func validateUserInfo(newDB bool) {
|
||||
logger.Info("Resetting admin user to default username and password.")
|
||||
user.Permissions = settings.AdminPerms()
|
||||
user.Password = settings.Config.Auth.AdminPassword
|
||||
user.LoginMethod = users.LoginMethodPassword
|
||||
changedFields = append(changedFields, "permissions", "password", "loginMethod")
|
||||
changedFields = append(changedFields, "permissions", "password")
|
||||
changePass = true
|
||||
}
|
||||
|
||||
@@ -128,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
|
||||
}
|
||||
|
||||
@@ -234,27 +275,35 @@ func updatePreviewSettings(user *users.User) bool {
|
||||
|
||||
// updateSidebarLinks normalizes sidebar links and ensures scoped sources have sidebar entries.
|
||||
func updateSidebarLinks(user *users.User) bool {
|
||||
needsEnsure := usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
|
||||
validBefore := usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks)
|
||||
updated := false
|
||||
|
||||
links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes)
|
||||
if !changed {
|
||||
return false
|
||||
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
|
||||
user.SidebarLinks = normalized
|
||||
updated = true
|
||||
}
|
||||
|
||||
user.SidebarLinks = links
|
||||
|
||||
if needsEnsure {
|
||||
if validBefore == 0 && len(user.SidebarLinks) > 0 {
|
||||
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
|
||||
} else if validBefore == 0 {
|
||||
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
|
||||
} else {
|
||||
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
|
||||
}
|
||||
if !usersidebar.NeedsSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes) {
|
||||
return updated
|
||||
}
|
||||
|
||||
return true
|
||||
if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 && len(user.SidebarLinks) > 0 {
|
||||
logger.Infof("User %s has stale source sidebar links, merging missing links from scopes", user.Username)
|
||||
} else if usersidebar.ValidSourceSidebarLinkCount(user.SidebarLinks) == 0 {
|
||||
logger.Infof("User %s has no source sidebar links, building from scopes", user.Username)
|
||||
} else {
|
||||
logger.Infof("User %s is missing sidebar links for some scoped sources, merging from scopes", user.Username)
|
||||
}
|
||||
|
||||
merged, changed := usersidebar.EnsureSidebarLinksFromScopes(user.SidebarLinks, user.BackendScopes)
|
||||
if changed {
|
||||
user.SidebarLinks = merged
|
||||
updated = true
|
||||
}
|
||||
if normalized, changed := usersidebar.NormalizeSidebarLinks(user.SidebarLinks); changed {
|
||||
user.SidebarLinks = normalized
|
||||
updated = true
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func updateTokens(user *users.User) bool {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
userDefaults:
|
||||
preview:
|
||||
image: true
|
||||
video: true
|
||||
audio: true
|
||||
motionVideoPreview: true
|
||||
office: true
|
||||
popup: true
|
||||
folder: true
|
||||
models: true
|
||||
+111
-114
@@ -1,66 +1,65 @@
|
||||
module github.com/gtsteffaniak/filebrowser/backend
|
||||
|
||||
go 1.27.0
|
||||
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.21.0
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/evanoberholster/imagemeta v1.0.0
|
||||
github.com/gen2brain/go-fitz v1.28.2
|
||||
github.com/go-ldap/ldap/v3 v3.4.14
|
||||
github.com/go-playground/validator/v10 v10.30.4
|
||||
github.com/go-webauthn/webauthn v0.18.0
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v0.5.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.56.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.58.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
|
||||
github.com/4meepo/tagalign v1.4.4 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.8 // indirect
|
||||
dev.gaijin.team/go/golib v0.6.0 // indirect
|
||||
github.com/4meepo/tagalign v1.4.3 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.7 // indirect
|
||||
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
|
||||
@@ -68,74 +67,76 @@ require (
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.67.4 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.4.0 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
github.com/alexkohler/prealloc v1.1.0 // indirect
|
||||
github.com/alfatraining/structtag v1.0.0 // indirect
|
||||
github.com/alingse/asasalint v0.0.11 // indirect
|
||||
github.com/alingse/nilnesserr v0.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.3 // 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-20260903151058-ae99b731b8c5 // 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
|
||||
github.com/ebitengine/purego v0.11.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.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 v1.0.0 // 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.15.0 // 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.5 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.1 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.2 // indirect
|
||||
github.com/go-openapi/spec v1.0.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.29.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-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/spec v0.22.9 // indirect
|
||||
github.com/go-openapi/swag/conv v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/pools v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
|
||||
github.com/go-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,27 +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.4 // indirect
|
||||
github.com/gofrs/flock v0.13.1 // indirect
|
||||
github.com/gohugoio/hashstructure v1.1.0 // indirect
|
||||
github.com/gohugoio/hugo v0.165.0 // indirect
|
||||
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
||||
github.com/gohugoio/hugo v0.164.0 // indirect
|
||||
github.com/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/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.13.2 // 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-20260901154044-241134db7241 // 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
|
||||
@@ -178,104 +178,109 @@ 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
|
||||
github.com/kulti/thelper v0.7.1 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.15 // indirect
|
||||
github.com/lasiar/canonicalheader v1.1.2 // indirect
|
||||
github.com/ldez/exptostd v0.4.5 // indirect
|
||||
github.com/ldez/gomoddirectives v0.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.29 // 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.3 // indirect
|
||||
github.com/prometheus/common v0.71.0 // indirect
|
||||
github.com/prometheus/procfs v0.22.0 // 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.1 // 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.29.0 // indirect
|
||||
github.com/sirupsen/logrus v1.10.2 // 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/timonwong/loggercheck v0.12.0 // 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.1 // 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-20260824195058-e88cd73687aa // 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.7 // 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-20260823230713-2fa3d841b0c8 // 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 (
|
||||
@@ -309,11 +314,3 @@ tool (
|
||||
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
|
||||
github.com/swaggo/swag/cmd/swag
|
||||
)
|
||||
|
||||
exclude (
|
||||
github.com/alecthomas/go-check-sumtype v0.5.0
|
||||
github.com/gobwas/glob v1.0.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.0.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v1.2.0
|
||||
)
|
||||
+725
-277
File diff suppressed because it is too large.
Load diff
@@ -15,13 +15,13 @@ import (
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -131,10 +131,10 @@ func checkPermissionsImpl(opts utils.FileOptions, user *users.User, s *Service)
|
||||
return "", "", fmt.Errorf("user not provided")
|
||||
}
|
||||
if opts.Path == "" {
|
||||
return "", "", fmt.Errorf("%w: path not provided", errors.ErrInvalidRequestParams)
|
||||
return "", "", fmt.Errorf("path not provided")
|
||||
}
|
||||
if opts.Source == "" {
|
||||
return "", "", fmt.Errorf("%w: source not provided", errors.ErrInvalidRequestParams)
|
||||
return "", "", fmt.Errorf("source not provided")
|
||||
}
|
||||
|
||||
// Get index
|
||||
@@ -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 {
|
||||
@@ -403,10 +387,6 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
return
|
||||
}
|
||||
|
||||
if !opts.Content {
|
||||
return
|
||||
}
|
||||
|
||||
// Process text content for non-video, non-audio files
|
||||
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
|
||||
content, err := getContent(info.RealPath)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/ports"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Service performs authentication using injected user reads. It never persists users.
|
||||
@@ -73,7 +72,6 @@ func (s *Service) AuthenticatePassword(r *http.Request, disableOtp bool) (*users
|
||||
}
|
||||
}
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
logger.Debugf("login rejected: user %q attempted password login but account loginMethod is %q", username, user.LoginMethod)
|
||||
return nil, errors.ErrWrongLoginMethod
|
||||
}
|
||||
return &user, nil
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -23,9 +23,7 @@ var (
|
||||
ErrUnauthorized = errors.New("user unauthorized")
|
||||
ErrNotIndexed = errors.New("directory or item excluded from indexing")
|
||||
ErrNotViewable = errors.New("directory or item is not viewable")
|
||||
ErrPathEscapesScope = errors.New("resolved path escapes scope")
|
||||
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
|
||||
ErrInvalidLoginMethod = errors.New("invalid login method")
|
||||
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
|
||||
ErrUseMediaStream = errors.New("use /media/stream for audio and video")
|
||||
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -30,9 +28,8 @@ const (
|
||||
|
||||
// iconSize defines an icon size to generate
|
||||
type iconSize struct {
|
||||
size int
|
||||
name string
|
||||
maskable bool
|
||||
size int
|
||||
name string
|
||||
}
|
||||
|
||||
// determineSourceType determines which type of favicon source we have
|
||||
@@ -156,16 +153,13 @@ func handleRasterFavicon(faviconPath string) ([]byte, error) {
|
||||
func getIconSizesToGenerate() []iconSize {
|
||||
return []iconSize{
|
||||
// Browser favicon (32x32 PNG for Safari/older browsers)
|
||||
{32, "favicon-32x32.png", false},
|
||||
{32, "favicon-32x32.png"},
|
||||
// PWA icons (PNG required for Apple PWA compatibility)
|
||||
{192, "pwa-icon-192.png", false},
|
||||
{256, "pwa-icon-256.png", false}, // Also serves as Windows tile
|
||||
{512, "pwa-icon-512.png", false},
|
||||
// Maskable PWA icons: art inset into the Android adaptive-icon safe zone
|
||||
{192, "pwa-icon-maskable-192.png", true},
|
||||
{512, "pwa-icon-maskable-512.png", true},
|
||||
{192, "pwa-icon-192.png"},
|
||||
{256, "pwa-icon-256.png"}, // Also serves as Windows tile
|
||||
{512, "pwa-icon-512.png"},
|
||||
// Platform-specific icons
|
||||
{180, "apple-touch-icon.png", false}, // iOS home screen (required for Apple)
|
||||
{180, "apple-touch-icon.png"}, // iOS home screen (required for Apple)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,55 +193,8 @@ func generateIconSizes(sourceData []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateMaskableIcon insets the art into the safe zone on an opaque background, since launchers zoom maskable icons to fill the mask edge-to-edge
|
||||
func generateMaskableIcon(sourceData []byte, icon iconSize) error {
|
||||
img, err := imaging.Decode(bytes.NewReader(sourceData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode source: %w", err)
|
||||
}
|
||||
|
||||
// Android adaptive-icon content ratio (72/108 ≈ 2/3) keeps square art inside every mask shape
|
||||
inner := icon.size * 2 / 3
|
||||
art := imaging.Fit(img, inner, inner, imaging.Lanczos)
|
||||
canvas := imaging.PasteCenter(imaging.New(icon.size, icon.size, maskableBackground(img)), art)
|
||||
|
||||
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
if err := imaging.Encode(outFile, canvas, imaging.PNG); err != nil {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("encode failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskableBackground samples the art's corners then edge midpoints for an opaque pad color, else white
|
||||
func maskableBackground(img image.Image) color.Color {
|
||||
b := img.Bounds()
|
||||
cx, cy := (b.Min.X+b.Max.X)/2, (b.Min.Y+b.Max.Y)/2
|
||||
points := []image.Point{
|
||||
{b.Min.X, b.Min.Y}, {b.Max.X - 1, b.Min.Y}, {b.Min.X, b.Max.Y - 1}, {b.Max.X - 1, b.Max.Y - 1},
|
||||
{cx, b.Min.Y}, {cx, b.Max.Y - 1}, {b.Min.X, cy}, {b.Max.X - 1, cy},
|
||||
}
|
||||
for _, p := range points {
|
||||
r, g, bl, a := img.At(p.X, p.Y).RGBA()
|
||||
if a == 0xffff {
|
||||
return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(bl >> 8), A: 255}
|
||||
}
|
||||
}
|
||||
return color.White
|
||||
}
|
||||
|
||||
// generateSingleIcon generates a single icon file at the specified size
|
||||
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
|
||||
if icon.maskable {
|
||||
return generateMaskableIcon(sourceData, icon)
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(settings.PWAIconsCacheDir(), icon.name)
|
||||
|
||||
// Create output file
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestMaskableBackgroundOpaqueCorner(t *testing.T) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
|
||||
img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
|
||||
|
||||
got := maskableBackground(img)
|
||||
want := color.NRGBA{R: 10, G: 20, B: 30, A: 255}
|
||||
if got != want {
|
||||
t.Fatalf("opaque corner: got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskableBackgroundOpaqueEdge(t *testing.T) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
|
||||
img.Set(6, 0, color.RGBA{R: 200, G: 100, B: 50, A: 255})
|
||||
|
||||
got := maskableBackground(img)
|
||||
want := color.NRGBA{R: 200, G: 100, B: 50, A: 255}
|
||||
if got != want {
|
||||
t.Fatalf("opaque edge: got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskableBackgroundAllTransparent(t *testing.T) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 12, 12))
|
||||
if got := maskableBackground(img); got != color.White {
|
||||
t.Fatalf("all transparent: got %#v, want white", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBackgroundColor(t *testing.T) {
|
||||
saved := settings.Config
|
||||
defer func() { settings.Config = saved }()
|
||||
|
||||
dark := true
|
||||
light := false
|
||||
|
||||
settings.Config.Frontend.Styling.DarkBackground = "#111111"
|
||||
settings.Config.Frontend.Styling.LightBackground = "#ffffff"
|
||||
|
||||
settings.Config.UserDefaults.UI.DarkMode = &dark
|
||||
if got := settings.DefaultBackgroundColor(); got != "#111111" {
|
||||
t.Fatalf("dark branch: got %q, want %q", got, "#111111")
|
||||
}
|
||||
|
||||
settings.Config.UserDefaults.UI.DarkMode = &light
|
||||
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
|
||||
t.Fatalf("light branch (dark disabled): got %q, want %q", got, "#ffffff")
|
||||
}
|
||||
|
||||
settings.Config.UserDefaults.UI.DarkMode = nil
|
||||
if got := settings.DefaultBackgroundColor(); got != "#ffffff" {
|
||||
t.Fatalf("light branch (unset): got %q, want %q", got, "#ffffff")
|
||||
}
|
||||
}
|
||||
@@ -44,14 +44,14 @@ func pwaManifestName(name string) string {
|
||||
}
|
||||
|
||||
// generatePWAManifest creates the PWA manifest structure
|
||||
func generatePWAManifest(name, description, baseURL, themeColor, backgroundColor, pwaIcon192, pwaIcon256, pwaIcon512, pwaIconMaskable192, pwaIconMaskable512 string) PWAManifest {
|
||||
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
|
||||
return PWAManifest{
|
||||
Name: pwaManifestName(name),
|
||||
ID: baseURL,
|
||||
Scope: baseURL,
|
||||
StartURL: baseURL,
|
||||
Display: "standalone",
|
||||
BackgroundColor: backgroundColor,
|
||||
BackgroundColor: "#ffffff",
|
||||
ThemeColor: themeColor,
|
||||
Description: description,
|
||||
Icons: []PWAIcon{
|
||||
@@ -65,7 +65,7 @@ func generatePWAManifest(name, description, baseURL, themeColor, backgroundColor
|
||||
Src: pwaIcon256,
|
||||
Sizes: "256x256",
|
||||
Type: "image/png",
|
||||
Purpose: "any",
|
||||
Purpose: "any maskable",
|
||||
},
|
||||
{
|
||||
Src: pwaIcon512,
|
||||
@@ -73,18 +73,6 @@ func generatePWAManifest(name, description, baseURL, themeColor, backgroundColor
|
||||
Type: "image/png",
|
||||
Purpose: "any",
|
||||
},
|
||||
{
|
||||
Src: pwaIconMaskable192,
|
||||
Sizes: "192x192",
|
||||
Type: "image/png",
|
||||
Purpose: "maskable",
|
||||
},
|
||||
{
|
||||
Src: pwaIconMaskable512,
|
||||
Sizes: "512x512",
|
||||
Type: "image/png",
|
||||
Purpose: "maskable",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -97,13 +85,11 @@ func InitializePWAManifest() {
|
||||
staticURL := config.Http.BaseURL + "public/static"
|
||||
title := config.Frontend.Name
|
||||
description := config.Frontend.Description
|
||||
defaultThemeColor := "#455a64"
|
||||
|
||||
pwaIcon192 := staticURL + "/icons/pwa-icon-192.png"
|
||||
pwaIcon256 := staticURL + "/icons/pwa-icon-256.png"
|
||||
pwaIcon512 := staticURL + "/icons/pwa-icon-512.png"
|
||||
pwaIconMaskable192 := staticURL + "/icons/pwa-icon-maskable-192.png"
|
||||
pwaIconMaskable512 := staticURL + "/icons/pwa-icon-maskable-512.png"
|
||||
|
||||
backgroundColor := settings.DefaultBackgroundColor()
|
||||
|
||||
CachedManifest = generatePWAManifest(title, description, config.Http.BaseURL, backgroundColor, backgroundColor, pwaIcon192, pwaIcon256, pwaIcon512, pwaIconMaskable192, pwaIconMaskable512)
|
||||
CachedManifest = generatePWAManifest(title, description, config.Http.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
|
||||
}
|
||||
@@ -56,8 +56,6 @@ func ExtractEmbeddedPreview(ctx context.Context, path string) ([]byte, error) {
|
||||
switch {
|
||||
case ext == ".cr3":
|
||||
data, err = extimagemeta.PreviewCR3(f)
|
||||
case ext == ".raf":
|
||||
data, err = extractRAFEmbeddedPreview(f)
|
||||
case isRawImageExtension(ext):
|
||||
data, err = extractTIFFEmbeddedPreview(f)
|
||||
case isHEICExtension(ext):
|
||||
@@ -83,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 {
|
||||
@@ -134,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[:]...)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
//go:build !386 && !arm
|
||||
|
||||
package imagemeta
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
rafMagicLen = 16
|
||||
rafMagic = "FUJIFILMCCD-RAW "
|
||||
rafJPEGOffsetPos = 0x54
|
||||
rafJPEGLengthPos = 0x58
|
||||
rafHeaderMinBytes = 0x5C
|
||||
)
|
||||
|
||||
// extractRAFEmbeddedPreview reads the camera-embedded JPEG from a Fuji RAF file.
|
||||
// Offset and length are stored as big-endian uint32 at 0x54 and 0x58 in the RAF header directory.
|
||||
func extractRAFEmbeddedPreview(f *os.File) ([]byte, error) {
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Size() < rafHeaderMinBytes {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
header := make([]byte, rafHeaderMinBytes)
|
||||
n, err := f.ReadAt(header, 0)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
if n < rafHeaderMinBytes {
|
||||
return nil, nil
|
||||
}
|
||||
if !isRAFMagic(header[:rafMagicLen]) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
offset := binary.BigEndian.Uint32(header[rafJPEGOffsetPos:rafJPEGLengthPos])
|
||||
length := binary.BigEndian.Uint32(header[rafJPEGLengthPos:rafHeaderMinBytes])
|
||||
if offset == 0 || length == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if int64(offset)+int64(length) > info.Size() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := readFileRange(f, offset, length)
|
||||
if err != nil || len(data) == 0 || !IsJPEG(data) {
|
||||
return nil, nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func isRAFMagic(b []byte) bool {
|
||||
if len(b) < len(rafMagic) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(rafMagic); i++ {
|
||||
if b[i] != rafMagic[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
//go:build !386 && !arm
|
||||
|
||||
package imagemeta
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractRAFEmbeddedPreviewSynthetic(t *testing.T) {
|
||||
jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9}
|
||||
const jpegOffset = uint32(0x94)
|
||||
|
||||
data := make([]byte, int(jpegOffset)+len(jpeg))
|
||||
copy(data[:rafMagicLen], []byte(rafMagic))
|
||||
binary.BigEndian.PutUint32(data[rafJPEGOffsetPos:rafJPEGLengthPos], jpegOffset)
|
||||
binary.BigEndian.PutUint32(data[rafJPEGLengthPos:rafHeaderMinBytes], uint32(len(jpeg)))
|
||||
copy(data[jpegOffset:], jpeg)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "sample.raf")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ExtractEmbeddedPreview(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsJPEG(got) {
|
||||
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
|
||||
}
|
||||
if len(got) != len(jpeg) {
|
||||
t.Fatalf("preview size = %d, want %d", len(got), len(jpeg))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRAFEmbeddedPreviewIgnoresInvalidMagic(t *testing.T) {
|
||||
data := make([]byte, 256)
|
||||
copy(data[:rafMagicLen], []byte("FUJIFILMCCD-RAW!"))
|
||||
path := filepath.Join(t.TempDir(), "bad.raf")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ExtractEmbeddedPreview(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("ExtractEmbeddedPreview() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRAFEmbeddedPreviewDSCF1276(t *testing.T) {
|
||||
path := "/Users/steffag/Downloads/DSCF1276.RAF"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skip(path)
|
||||
}
|
||||
|
||||
got, err := ExtractEmbeddedPreview(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsJPEG(got) {
|
||||
t.Fatalf("expected JPEG preview, got %d bytes", len(got))
|
||||
}
|
||||
if len(got) < 100 {
|
||||
t.Fatalf("preview size = %d, want >= 100", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRAFEmbeddedPreviewRejectsOutOfBoundsOffset(t *testing.T) {
|
||||
jpeg := []byte{0xff, 0xd8, 0xff, 0xd9}
|
||||
data := make([]byte, 256)
|
||||
copy(data[:rafMagicLen], []byte(rafMagic))
|
||||
binary.BigEndian.PutUint32(data[rafJPEGOffsetPos:rafJPEGLengthPos], 200)
|
||||
binary.BigEndian.PutUint32(data[rafJPEGLengthPos:rafHeaderMinBytes], uint32(len(jpeg)))
|
||||
|
||||
path := filepath.Join(t.TempDir(), "oob.raf")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := ExtractEmbeddedPreview(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("ExtractEmbeddedPreview() = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
gofitzinclude
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,19 +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) {
|
||||
if s.ffmpegService != nil {
|
||||
previewBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err == nil && len(previewBytes) >= minPreviewSize {
|
||||
return previewBytes, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return nil, ErrUnsupportedFormat
|
||||
}
|
||||
return s.generateImagePreview(ctx, file, previewSize)
|
||||
|
||||
case previewTypeVideo:
|
||||
@@ -303,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)
|
||||
}
|
||||
@@ -377,13 +364,6 @@ func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.Extend
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
format, err := s.FormatFromExtension(ext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options.Format = format
|
||||
|
||||
imageBytes, err := s.CreatePreview(ctx, f, file.Size, options)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
@@ -435,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
|
||||
@@ -505,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,119 +0,0 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func TestGenerateRawPreviewRawWithoutEmbeddedReturnsUnsupported(t *testing.T) {
|
||||
data := make([]byte, 256)
|
||||
copy(data[:16], []byte("FUJIFILMCCD-RAW "))
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "empty.raf")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewPreviewGenerator(1, dir)
|
||||
file := iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "empty.raf",
|
||||
Size: int64(len(data)),
|
||||
ModTime: time.Now(),
|
||||
Type: "image/x-fuji-raf",
|
||||
},
|
||||
},
|
||||
RealPath: path,
|
||||
}
|
||||
|
||||
_, err := svc.generateRawPreview(context.Background(), file, "small", "", 0, "hash")
|
||||
if !errors.Is(err, ErrUnsupportedFormat) {
|
||||
t.Fatalf("generateRawPreview() err = %v, want ErrUnsupportedFormat", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "SOI") {
|
||||
t.Fatalf("generateRawPreview() should fail fast, got JPEG decode error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImagePreviewUnsupportedExtensionReturnsUnsupported(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "clip.mov")
|
||||
// Must exceed maxSizeForOriginal (256KB) so we don't short-circuit to raw bytes.
|
||||
payload := make([]byte, 256*1024+1)
|
||||
copy(payload, []byte("not a video"))
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewPreviewGenerator(1, dir)
|
||||
file := iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "clip.mov",
|
||||
Size: int64(len(payload)),
|
||||
ModTime: time.Now(),
|
||||
Type: "video/quicktime",
|
||||
},
|
||||
},
|
||||
RealPath: path,
|
||||
}
|
||||
|
||||
_, err := svc.generateImagePreview(context.Background(), file, "small")
|
||||
if !errors.Is(err, ErrUnsupportedFormat) {
|
||||
t.Fatalf("generateImagePreview() err = %v, want ErrUnsupportedFormat", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateImagePreviewUsesPNGFormat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svc := NewPreviewGenerator(1, dir)
|
||||
|
||||
// 1x1 red PNG; pad past maxSizeForOriginal (256KB) so we exercise format selection.
|
||||
const maxSizeForOriginal = 256 * 1024
|
||||
pngBytes := []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
||||
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
|
||||
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
||||
0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
|
||||
0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
|
||||
0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
}
|
||||
payload := make([]byte, maxSizeForOriginal+1)
|
||||
copy(payload, pngBytes)
|
||||
path := filepath.Join(dir, "pixel.png")
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
file := iteminfo.ExtendedFileInfo{
|
||||
FileInfo: iteminfo.FileInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "pixel.png",
|
||||
Size: int64(len(payload)),
|
||||
ModTime: time.Now(),
|
||||
Type: "image/png",
|
||||
},
|
||||
},
|
||||
RealPath: path,
|
||||
}
|
||||
|
||||
got, err := svc.generateImagePreview(context.Background(), file, "small")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) < 8 || string(got[:8]) != "\x89PNG\r\n\x1a\n" {
|
||||
t.Fatalf("expected PNG signature, got %x", got[:min(8, len(got))])
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,7 @@ func initialize(dbPath string) (bool, error) {
|
||||
defer sharesMux.Unlock()
|
||||
defer indexMux.Unlock()
|
||||
|
||||
if !settings.Env.IsCLIMode {
|
||||
logger.Info("Initializing state management system...")
|
||||
}
|
||||
logger.Info("Initializing state management system...")
|
||||
var existingDb bool
|
||||
var err error
|
||||
sqlDb, existingDb, err = sqldb.NewSQLStore(dbPath)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
)
|
||||
|
||||
// User operations
|
||||
@@ -222,10 +221,6 @@ func CreateUser(user *users.User, plaintextPassword string) error {
|
||||
|
||||
users.SyncBackendSourcePermissionsMap(user)
|
||||
|
||||
if links, changed := usersidebar.PrepareSidebarLinksForPersist(user.SidebarLinks, user.BackendScopes); changed {
|
||||
user.SidebarLinks = links
|
||||
}
|
||||
|
||||
usersMux.Lock()
|
||||
defer usersMux.Unlock()
|
||||
|
||||
@@ -333,19 +328,6 @@ func UpdateUser(user *users.User, plaintextPassword string, fields ...string) er
|
||||
}
|
||||
}
|
||||
|
||||
if sidebarFieldsPatched(fields) {
|
||||
var links []users.SidebarLink
|
||||
var changed bool
|
||||
if fieldListRequiresScopeConversion(fields) {
|
||||
links, changed = usersidebar.PrepareSidebarLinksForPersist(existingUser.SidebarLinks, existingUser.BackendScopes)
|
||||
} else {
|
||||
links, changed = usersidebar.NormalizeSidebarLinks(existingUser.SidebarLinks)
|
||||
}
|
||||
if changed {
|
||||
existingUser.SidebarLinks = links
|
||||
}
|
||||
}
|
||||
|
||||
return commitUserUpdate(existingUser, storedSnapshot, sourceDefaults, sourceEnforced)
|
||||
}
|
||||
|
||||
@@ -408,10 +390,6 @@ func fieldListRequiresScopeConversion(fields []string) bool {
|
||||
return fieldListPatchesBackendScopes(fields) || fieldListPatchesAPISourcePermissions(fields)
|
||||
}
|
||||
|
||||
func sidebarFieldsPatched(fields []string) bool {
|
||||
return FieldListIncludes(fields, "sidebarLinks") || fieldListRequiresScopeConversion(fields)
|
||||
}
|
||||
|
||||
// applyScopesFromAPI converts API scopes (with nested permissions) into BackendScopes.
|
||||
// Legacy sourcePermissions map is merged into scopes when permissions are omitted per scope.
|
||||
func applyScopesFromAPI(user *users.User) error {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func initSidebarLinkTestDB(t *testing.T) {
|
||||
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
|
||||
settings.Initialize("../../../_docker/src/noauth/backend/config.yaml")
|
||||
settings.Env.IsPlaywright = true
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
|
||||
if _, err := Initialize(dbPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Close() })
|
||||
}
|
||||
|
||||
func countSourceSidebarLinks(links []users.SidebarLink) int {
|
||||
n := 0
|
||||
for _, link := range links {
|
||||
if strings.HasPrefix(link.Category, "source") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestCreateUserAddsSidebarLinksForAllScopes(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "scope-user",
|
||||
FrontendScopes: []users.FrontendScope{
|
||||
{Name: "exclude", Scope: "/"},
|
||||
{Name: "include", Scope: "/"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := GetUserByUsername("scope-user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
|
||||
t.Fatalf("source sidebar links = %d, want 2: %#v", count, loaded.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserScopesAddsMissingSidebarLink(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "patch-user",
|
||||
FrontendScopes: []users.FrontendScope{
|
||||
{Name: "exclude", Scope: "/"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
patch := &users.User{
|
||||
ID: u.ID,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "patch-user",
|
||||
FrontendScopes: []users.FrontendScope{
|
||||
{Name: "exclude", Scope: "/"},
|
||||
{Name: "include", Scope: "/"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := UpdateUser(patch, "", "scopes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := GetUserByUsername("patch-user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
|
||||
t.Fatalf("source sidebar links = %d, want 2: %#v", count, loaded.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserScopesKeepsStaleSidebarLinks(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "stale-user",
|
||||
FrontendScopes: []users.FrontendScope{
|
||||
{Name: "exclude", Scope: "/"},
|
||||
{Name: "include", Scope: "/"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
patch := &users.User{
|
||||
ID: u.ID,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "stale-user",
|
||||
FrontendScopes: []users.FrontendScope{
|
||||
{Name: "exclude", Scope: "/"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := UpdateUser(patch, "", "scopes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := GetUserByUsername("stale-user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count := countSourceSidebarLinks(loaded.SidebarLinks); count != 2 {
|
||||
t.Fatalf("expected stale include link kept, got %d links: %#v", count, loaded.SidebarLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserSidebarLinksPreservesFolderShortcuts(t *testing.T) {
|
||||
initSidebarLinkTestDB(t)
|
||||
|
||||
u := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "folder-user",
|
||||
FrontendScopes: []users.FrontendScope{
|
||||
{Name: "exclude", Scope: "/"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := CreateUser(u, "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
links := []users.SidebarLink{
|
||||
{Name: "exclude", Category: "source", SourceName: "exclude", Target: "/"},
|
||||
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: "exclude", Target: "/photos"},
|
||||
{Name: "divider", Category: "divider", Target: "#"},
|
||||
}
|
||||
patch := &users.User{
|
||||
ID: u.ID,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "folder-user",
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
SidebarLinks: links,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := UpdateUser(patch, "", "sidebarLinks"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := GetUserByUsername("folder-user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.SidebarLinks) != 3 {
|
||||
t.Fatalf("len(sidebarLinks) = %d, want 3: %#v", len(loaded.SidebarLinks), loaded.SidebarLinks)
|
||||
}
|
||||
if loaded.SidebarLinks[1].Name != "Photos" || loaded.SidebarLinks[1].Icon != "photo" || loaded.SidebarLinks[1].Target != "/photos" {
|
||||
t.Fatalf("folder shortcut not preserved: %#v", loaded.SidebarLinks[1])
|
||||
}
|
||||
if !strings.Contains(loaded.SidebarLinks[0].SourceName, "playwright-files") {
|
||||
t.Fatalf("expected canonical path persisted, got SourceName=%q", loaded.SidebarLinks[0].SourceName)
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,9 @@ import (
|
||||
|
||||
// NormalizeSidebarLinks canonicalizes persisted sidebar links for storage.
|
||||
// Source links are resolved via ResolveSourceKey on SourceName, with Name as fallback.
|
||||
// SourceName is set to the canonical backend path; custom Name, Icon, and Category are preserved.
|
||||
// Unresolvable source links are dropped. Source links are deduped by canonical path plus target
|
||||
// (first occurrence wins), so multiple folder shortcuts on one source are kept. Non-source links
|
||||
// pass through; duplicate Tools links are deduped.
|
||||
// SourceName is set to the canonical backend path; Name to the configured display name.
|
||||
// Unresolvable source links are dropped. Source links are deduped by canonical path
|
||||
// (first occurrence wins). Non-source links pass through; duplicate Tools links are deduped.
|
||||
func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool) {
|
||||
if !users.SourceConfigLoaded() {
|
||||
return links, false
|
||||
@@ -20,7 +19,7 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
|
||||
return links, false
|
||||
}
|
||||
|
||||
seenSourceKeys := make(map[sourceLinkDedupeKey]struct{})
|
||||
seenSourcePaths := make(map[string]struct{})
|
||||
hasTools := false
|
||||
out := make([]users.SidebarLink, 0, len(links))
|
||||
changed := false
|
||||
@@ -32,6 +31,11 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if _, dup := seenSourcePaths[source.Path]; dup {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
seenSourcePaths[source.Path] = struct{}{}
|
||||
|
||||
normalized := link
|
||||
normalized.Category = users.NormalizeSidebarLinkCategory(normalized.Category)
|
||||
@@ -39,15 +43,9 @@ func NormalizeSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, bool
|
||||
if strings.TrimSpace(normalized.Name) == "" {
|
||||
normalized.Name = source.Name
|
||||
}
|
||||
normalized.Target = normalizeSidebarTarget(normalized.Target)
|
||||
|
||||
key := sourceLinkDedupeKey{sourcePath: source.Path, target: normalized.Target}
|
||||
if _, dup := seenSourceKeys[key]; dup {
|
||||
changed = true
|
||||
continue
|
||||
if normalized.Target == "" {
|
||||
normalized.Target = "/"
|
||||
}
|
||||
seenSourceKeys[key] = struct{}{}
|
||||
|
||||
if normalized != link {
|
||||
changed = true
|
||||
}
|
||||
@@ -79,22 +77,3 @@ func resolveSourceLink(link users.SidebarLink) (users.SourceInfo, bool) {
|
||||
}
|
||||
return users.SourceInfo{}, false
|
||||
}
|
||||
|
||||
func normalizeSidebarTarget(target string) string {
|
||||
t := strings.TrimSpace(target)
|
||||
if t == "" || t == "#" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(t, "/") {
|
||||
t = "/" + t
|
||||
}
|
||||
if t != "/" && strings.HasSuffix(t, "/") {
|
||||
t = strings.TrimSuffix(t, "/")
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
type sourceLinkDedupeKey struct {
|
||||
sourcePath string
|
||||
target string
|
||||
}
|
||||
@@ -164,88 +164,6 @@ func TestNormalizeSidebarLinks_dedupesToolsLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSidebarLinks_preservesMultipleFolderShortcutsOnSameSource(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
in := []users.SidebarLink{
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
|
||||
{Name: "Docs", Category: string(users.SidebarLinkSourceAlt), Icon: "folder", SourceName: ".", Target: "/docs"},
|
||||
{Name: "divider", Category: "divider", Target: "#"},
|
||||
}
|
||||
|
||||
out, changed := NormalizeSidebarLinks(in)
|
||||
if changed {
|
||||
t.Fatal("expected canonical folder shortcuts unchanged")
|
||||
}
|
||||
if len(out) != 4 {
|
||||
t.Fatalf("len(out) = %d, want 4", len(out))
|
||||
}
|
||||
if out[1].Name != "Photos" || out[1].Icon != "photo" || out[1].Target != "/photos" {
|
||||
t.Fatalf("photos link = %#v", out[1])
|
||||
}
|
||||
if out[2].Category != string(users.SidebarLinkSourceAlt) {
|
||||
t.Fatalf("docs category = %q", out[2].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSidebarLinks_dedupesSameSourceAndTarget(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
in := []users.SidebarLink{
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "docker root", Category: "source-minimal", Icon: "home", SourceName: "docker", Target: "/"},
|
||||
}
|
||||
|
||||
out, changed := NormalizeSidebarLinks(in)
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true")
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len(out) = %d, want 1", len(out))
|
||||
}
|
||||
if out[0].Name != "docker" {
|
||||
t.Fatalf("first link wins: %#v", out[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSidebarLinks_noCollisionOnPipeInPathOrTarget(t *testing.T) {
|
||||
had := users.SourceConfigLoaded()
|
||||
t.Cleanup(func() {
|
||||
if !had {
|
||||
users.SetSourceConfig(nil)
|
||||
}
|
||||
})
|
||||
users.SetSourceConfig(&users.SourceConfigProvider{
|
||||
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
|
||||
switch path {
|
||||
case "a":
|
||||
return users.SourceInfo{Path: "a", Name: "src-a"}, true
|
||||
case "a|/b":
|
||||
return users.SourceInfo{Path: "a|/b", Name: "src-pipe"}, true
|
||||
default:
|
||||
return users.SourceInfo{}, false
|
||||
}
|
||||
},
|
||||
GetSourceByName: func(name string) (users.SourceInfo, bool) {
|
||||
return users.SourceInfo{}, false
|
||||
},
|
||||
})
|
||||
|
||||
in := []users.SidebarLink{
|
||||
{Name: "one", Category: "source", SourceName: "a", Target: "/b|/c"},
|
||||
{Name: "two", Category: "source", SourceName: "a|/b", Target: "/c"},
|
||||
}
|
||||
|
||||
out, changed := NormalizeSidebarLinks(in)
|
||||
if changed {
|
||||
t.Fatal("expected changed=false for distinct path/target pairs")
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("len(out) = %d, want 2 (no false dedupe)", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSidebarLinks_noConfigLoaded(t *testing.T) {
|
||||
had := users.SourceConfigLoaded()
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package usersidebar
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
// PrepareSidebarLinksForPersist normalizes sidebar links and adds missing scoped source entries.
|
||||
// Inaccessible sources are not pruned; callers rely on the UI to gray out stale links.
|
||||
func PrepareSidebarLinksForPersist(links []users.SidebarLink, scopes []users.BackendScope) ([]users.SidebarLink, bool) {
|
||||
updated := false
|
||||
|
||||
if normalized, changed := NormalizeSidebarLinks(links); changed {
|
||||
links = normalized
|
||||
updated = true
|
||||
}
|
||||
|
||||
merged, changed := EnsureSidebarLinksFromScopes(links, scopes)
|
||||
if changed {
|
||||
links = merged
|
||||
updated = true
|
||||
if normalized, changed := NormalizeSidebarLinks(links); changed {
|
||||
links = normalized
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
|
||||
return links, updated
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package usersidebar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
)
|
||||
|
||||
func TestPrepareSidebarLinksForPersist_addsScopedSourcesAndPreservesFolders(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
links := []users.SidebarLink{
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
|
||||
}
|
||||
scopes := []users.BackendScope{
|
||||
{Path: "."},
|
||||
{Path: "../frontend/tests/playwright-files"},
|
||||
}
|
||||
|
||||
out, changed := PrepareSidebarLinksForPersist(links, scopes)
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true")
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len(out) = %d, want 3", len(out))
|
||||
}
|
||||
if out[1].Name != "Photos" || out[1].Icon != "photo" {
|
||||
t.Fatalf("folder shortcut lost: %#v", out[1])
|
||||
}
|
||||
if out[2].Name != "playwright + files" {
|
||||
t.Fatalf("added scope link = %#v", out[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSidebarLinksForPersist_keepsLinksWhenScopeRemoved(t *testing.T) {
|
||||
testSourceConfig(t)
|
||||
|
||||
links := []users.SidebarLink{
|
||||
{Name: "docker", Category: "source", SourceName: ".", Target: "/"},
|
||||
{Name: "Photos", Category: string(users.SidebarLinkSourceMinimal), Icon: "photo", SourceName: ".", Target: "/photos"},
|
||||
{Name: "playwright + files", Category: "source", SourceName: "../frontend/tests/playwright-files", Target: "/"},
|
||||
}
|
||||
scopes := []users.BackendScope{
|
||||
{Path: "."},
|
||||
}
|
||||
|
||||
out, changed := PrepareSidebarLinksForPersist(links, scopes)
|
||||
if changed {
|
||||
t.Fatal("expected changed=false when only pruning is not performed")
|
||||
}
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len(out) = %d, want 3 (stale scope link kept)", len(out))
|
||||
}
|
||||
}
|
||||
@@ -89,22 +89,3 @@ func CSPNonce() (string, error) {
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// CSPNonceFromData returns an existing cspNonce from map template data, or generates
|
||||
// and stores one when data is a map[string]interface{}. For other data types it
|
||||
// generates a fresh nonce without modifying data.
|
||||
func CSPNonceFromData(data interface{}) (string, error) {
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return CSPNonce()
|
||||
}
|
||||
if nonce, ok := m["cspNonce"].(string); ok && nonce != "" {
|
||||
return nonce, nil
|
||||
}
|
||||
nonce, err := CSPNonce()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("csp nonce: %w", err)
|
||||
}
|
||||
m["cspNonce"] = nonce
|
||||
return nonce, nil
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "/" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -28,13 +27,7 @@ func IsTextFile(realPath string) (bool, error) {
|
||||
if len(content) > sampleSize {
|
||||
sample = content[:sampleSize]
|
||||
}
|
||||
// Check 1: Reject PDFs. A PDF with a long ASCII text layer looks like text
|
||||
// for far more than sampleSize bytes.
|
||||
if http.DetectContentType(sample) == "application/pdf" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check 2: Count null bytes - binary files typically have many nulls
|
||||
// Check 1: Count null bytes - binary files typically have many nulls
|
||||
nullCount := 0
|
||||
for _, b := range sample {
|
||||
if b == 0x00 {
|
||||
@@ -49,7 +42,7 @@ func IsTextFile(realPath string) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check 3: Validate UTF-8 encoding
|
||||
// Check 2: Validate UTF-8 encoding
|
||||
// Trim sample to last complete UTF-8 rune to avoid false negatives
|
||||
trimmedSample := sample
|
||||
for len(trimmedSample) > 0 {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsTextFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
asciiPageStream := strings.Repeat("BT /Ft0 1 Tf <00430044004500460047> Tj ET\n", 300)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
want bool
|
||||
}{
|
||||
{name: "empty", content: nil, want: true},
|
||||
{name: "plain text", content: []byte("hello\nworld\n"), want: true},
|
||||
{name: "utf-8 text", content: []byte("café ☕\n"), want: true},
|
||||
{name: "svg", content: []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>`), want: true},
|
||||
{name: "binary with null bytes", content: append([]byte("GIF89a"), make([]byte, 64)...), want: false},
|
||||
{
|
||||
name: "pdf with an ascii text layer larger than the sample",
|
||||
content: append([]byte("%PDF-1.3\n"+asciiPageStream), 0xFF, 0xD8, 0xFF, 0xE0),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sample")
|
||||
if err := os.WriteFile(path, tt.content, 0o600); err != nil {
|
||||
t.Fatalf("writing sample: %v", err)
|
||||
}
|
||||
|
||||
got, err := IsTextFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("IsTextFile: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("IsTextFile(%s) = %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,18 +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 {
|
||||
logger.Debugf("login rejected: user %q attempted %s login but account loginMethod is %q", username, loginMethod, userValue.LoginMethod)
|
||||
return nil, errors.ErrWrongLoginMethod
|
||||
}
|
||||
// Sync IdP groups into access-control GroupMap (write-through). Skip when the
|
||||
// token/response omitted groups so a missing claim cannot wipe memberships.
|
||||
if len(groups) > 0 {
|
||||
if err := state.SyncUserGroups(username, groups); err != nil {
|
||||
logger.Warningf("failed to sync user %s groups: %v", username, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &userValue, nil
|
||||
}
|
||||
@@ -229,12 +224,22 @@ func sessionCookieDomain(r *http.Request) string {
|
||||
// @Success 200 {object} map[string]string "{"logoutUrl": "http://..."}"
|
||||
// @Router /api/auth/logout [post]
|
||||
func logoutHandler(w http.ResponseWriter, r *http.Request, d *Context) (int, error) {
|
||||
if err := state.RevokeToken(d.Token); err != nil {
|
||||
if err := state.RevokeToken( d.Token); err != nil {
|
||||
logger.Errorf("Failed to revoke token on logout: %v", err)
|
||||
}
|
||||
|
||||
// Clear the authentication cookie by setting it to expire in the past
|
||||
http.SetCookie(w, sessionCookie(r, "", time.Unix(0, 0), -1, http.SameSiteStrictMode))
|
||||
host := sessionCookieDomain(r)
|
||||
cookie := &http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: "",
|
||||
Domain: host,
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: time.Unix(0, 0), // Expire immediately
|
||||
MaxAge: -1, // Delete cookie
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
logoutUrl := fmt.Sprintf("%vlogin", settings.Config.Http.BaseURL) // Default fallback
|
||||
if d.User != nil && d.User.LoginMethod == users.LoginMethodProxy {
|
||||
@@ -418,27 +423,17 @@ func AuthenticateShareRequest(r *http.Request, l share.Share) (int, error) {
|
||||
return 200, nil
|
||||
}
|
||||
|
||||
const sessionCookieName = "filebrowser_quantum_jwt"
|
||||
|
||||
// sessionCookie builds the JWT cookie. HttpOnly prevents srcdoc XSS from reading
|
||||
// document.cookie; Secure is set when the request is HTTPS (including via proxy).
|
||||
func sessionCookie(r *http.Request, token string, expiresTime time.Time, maxAge int, sameSite http.SameSite) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
// SetSessionCookie sets the authentication token as an HTTP cookie.
|
||||
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
|
||||
cookie := &http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: token,
|
||||
Domain: sessionCookieDomain(r),
|
||||
Path: "/",
|
||||
SameSite: sameSite,
|
||||
HttpOnly: true,
|
||||
Secure: requestScheme(r) == "https",
|
||||
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
|
||||
Expires: expiresTime,
|
||||
MaxAge: maxAge,
|
||||
}
|
||||
}
|
||||
|
||||
// SetSessionCookie sets the authentication token as an HTTP cookie.
|
||||
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresTime time.Time) {
|
||||
http.SetCookie(w, sessionCookie(r, token, expiresTime, 0, http.SameSiteStrictMode))
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
// applyNamedApiTokenGlobalCaps intersects owner globals with JWT global caps for named custom API tokens.
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
libError "errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
)
|
||||
|
||||
// loginMethodHTTPStatus maps a login error to an HTTP status and client-safe message.
|
||||
func loginMethodHTTPStatus(err error) (int, error) {
|
||||
if libError.Is(err, errors.ErrWrongLoginMethod) {
|
||||
return http.StatusUnauthorized, errors.ErrInvalidLoginMethod
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/app"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
fberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestGetOrCreateAuthenticatedUserRejectsLoginMethodMismatch(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
passwordUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(passwordUser, "secret"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
_, err := getOrCreateAuthenticatedUser("graham", users.LoginMethodOidc, false, nil)
|
||||
if !errors.Is(err, fberrors.ErrWrongLoginMethod) {
|
||||
t.Fatalf("getOrCreateAuthenticatedUser() err = %v, want ErrWrongLoginMethod", err)
|
||||
}
|
||||
|
||||
loaded, err := state.GetUserByUsername("graham")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
if loaded.LoginMethod != users.LoginMethodPassword {
|
||||
t.Fatalf("loginMethod changed to %q, want password", loaded.LoginMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrCreateAuthenticatedUserRejectsPasswordUserForOIDCAccount(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
oidcUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(oidcUser, ""); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
_, err := getOrCreateAuthenticatedUser("graham", users.LoginMethodOidc, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("getOrCreateAuthenticatedUser() for matching oidc user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatePasswordRejectsWrongLoginMethod(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
|
||||
oidcUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(oidcUser, "secret"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=graham", nil)
|
||||
req.Header.Set("X-Password", url.QueryEscape("secret"))
|
||||
|
||||
_, err := auth.AuthenticatePassword(req, true)
|
||||
if !errors.Is(err, fberrors.ErrWrongLoginMethod) {
|
||||
t.Fatalf("AuthenticatePassword() err = %v, want ErrWrongLoginMethod", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginHelperReturnsInvalidLoginMethodForPasswordMismatch(t *testing.T) {
|
||||
setupTestEnv(t)
|
||||
app.MustWireServices(state.Default())
|
||||
|
||||
oidcUser := &users.User{
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "graham",
|
||||
LoginMethod: users.LoginMethodOidc,
|
||||
},
|
||||
}
|
||||
if err := state.CreateUser(oidcUser, "secret"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
origPassword := settings.Config.Auth.Methods.PasswordAuth.Enabled
|
||||
settings.Config.Auth.Methods.PasswordAuth.Enabled = true
|
||||
t.Cleanup(func() {
|
||||
settings.Config.Auth.Methods.PasswordAuth.Enabled = origPassword
|
||||
})
|
||||
|
||||
handler := LoginHelper(true, func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
return http.StatusOK, nil
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login?username=graham", nil)
|
||||
req.Header.Set("X-Password", url.QueryEscape("secret"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
status, err := handler(rec, req, &requestContext{})
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", status)
|
||||
}
|
||||
if !errors.Is(err, fberrors.ErrInvalidLoginMethod) {
|
||||
t.Fatalf("err = %v, want ErrInvalidLoginMethod", err)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
libErrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
@@ -128,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:
|
||||
@@ -140,8 +137,6 @@ func ErrToStatus(err error) int {
|
||||
return http.StatusBadRequest
|
||||
case errors.Is(err, libErrors.ErrIsDirectory):
|
||||
return http.StatusMethodNotAllowed
|
||||
case errors.Is(err, preview.ErrUnsupportedFormat):
|
||||
return http.StatusUnsupportedMediaType
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/preview"
|
||||
)
|
||||
|
||||
func TestErrToStatusUnsupportedPreviewFormat(t *testing.T) {
|
||||
status := ErrToStatus(preview.ErrUnsupportedFormat)
|
||||
if status != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrToStatusWrapsUnsupportedPreviewFormat(t *testing.T) {
|
||||
err := errors.Join(errors.New("preview failed"), preview.ErrUnsupportedFormat)
|
||||
status := ErrToStatus(err)
|
||||
if status != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("ErrToStatus() = %d, want %d", status, http.StatusUnsupportedMediaType)
|
||||
}
|
||||
}
|
||||
@@ -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.ResolveLdapGroupsClaim(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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -387,9 +382,6 @@ func LoginHelper(disableOtp bool, fn handleFunc) handleFunc {
|
||||
if err == errors.ErrNoTotpProvided {
|
||||
return 403, err
|
||||
}
|
||||
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
|
||||
return status, mapped
|
||||
}
|
||||
return 401, errors.ErrUnauthorized
|
||||
}
|
||||
d.User = user
|
||||
@@ -463,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
|
||||
@@ -507,9 +503,6 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
|
||||
// Setup user based on JWT claims
|
||||
user, err := SetupJwtUser(r, data, username, claims)
|
||||
if err != nil {
|
||||
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
|
||||
return status, mapped
|
||||
}
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
data.User = user
|
||||
@@ -541,9 +534,6 @@ func getProxyUser(w http.ResponseWriter, r *http.Request, data *requestContext,
|
||||
// proxy user logic
|
||||
user, err := SetupProxyUser(r, data, proxyUser)
|
||||
if err != nil {
|
||||
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
|
||||
return status, mapped
|
||||
}
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
data.User = user
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -239,10 +239,9 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
|
||||
|
||||
var userdata userInfo // Declare userdata here to be populated by either source
|
||||
var idTokenClaims map[string]any // Verified ID-token claims preserved across UserInfo fallback
|
||||
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
|
||||
loginUsername := "" // Variable to hold the login username
|
||||
var userdata userInfo // Declare userdata here to be populated by either source
|
||||
claimsFromIDToken := false // Flag to track if we successfully got claims from ID token
|
||||
loginUsername := "" // Variable to hold the login username
|
||||
|
||||
// Create custom unmarshaller for userInfo
|
||||
userInfoUnmarshaller := &userInfoUnmarshaller{
|
||||
@@ -268,19 +267,11 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
} else {
|
||||
// Successfully verified and decoded ID token claims
|
||||
logger.Debugf("ID Token verified and claims decoded: %+v", userdata)
|
||||
if len(userdata.Claims) > 0 {
|
||||
idTokenClaims = maps.Clone(userdata.Claims)
|
||||
}
|
||||
|
||||
// Decide if we rely on ID token claims or still need UserInfo
|
||||
// Even if parsing succeeded, if essential claims are missing, use UserInfo
|
||||
if _, ok := userdata.Claims[oidcCfg.UserIdentifier]; ok {
|
||||
claimsFromIDToken = true
|
||||
needsGroups := oidcCfg.AdminGroup != "" || len(oidcCfg.UserGroups) > 0
|
||||
if needsGroups && len(userdata.Groups) == 0 {
|
||||
logger.Debugf("OIDC: ID token missing groups claim (adminGroup/userGroups configured); fetching UserInfo")
|
||||
claimsFromIDToken = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,9 +296,6 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
logger.Errorf("failed to decode user info from endpoint: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to decode user info from endpoint: %v", err)
|
||||
}
|
||||
if len(idTokenClaims) > 0 {
|
||||
mergeMissingOidcClaims(&userdata, idTokenClaims)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Determine login username dynamically ---
|
||||
@@ -326,18 +314,6 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
return loginWithOidcUser(w, r, loginUsername, userdata.Groups)
|
||||
}
|
||||
|
||||
// mergeMissingOidcClaims copies claims from from into userdata when absent after UserInfo fallback.
|
||||
func mergeMissingOidcClaims(userdata *userInfo, from map[string]any) {
|
||||
if userdata.Claims == nil {
|
||||
userdata.Claims = make(map[string]any)
|
||||
}
|
||||
for k, v := range from {
|
||||
if _, exists := userdata.Claims[k]; !exists {
|
||||
userdata.Claims[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loginWithOidcUser extracts the username from the user claims (userInfo)
|
||||
// based on the configured UserIdentifier and logs the user into the application.
|
||||
// It creates a new user if one doesn't exist.
|
||||
@@ -376,9 +352,6 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
|
||||
user, err := getOrCreateAuthenticatedUser(username, users.LoginMethodOidc, isAdmin, groups)
|
||||
if err != nil {
|
||||
if status, mapped := loginMethodHTTPStatus(err); status != 0 {
|
||||
return status, mapped
|
||||
}
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
|
||||
@@ -397,8 +370,19 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
// This allows backend to identify expired sessions and provide better user feedback
|
||||
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
|
||||
|
||||
// Set the authentication token as an HTTP cookie (Lax for OIDC redirect return).
|
||||
http.SetCookie(w, sessionCookie(r, tokenString, expiresTime, 0, http.SameSiteLaxMode))
|
||||
// Set the authentication token as an HTTP cookie
|
||||
host := requestHost(r)
|
||||
cookie := &http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: tokenString,
|
||||
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
|
||||
Expires: expiresTime,
|
||||
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
|
||||
// Secure: true, // Enable this in production with HTTPS
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
// Set the authenticated user in the request context or response writer
|
||||
// This allows subsequent handlers to access the current user.
|
||||
|
||||
@@ -206,42 +206,6 @@ func TestUserInfoUnmarshaller_InvalidJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMissingOidcClaims_PreservesIDTokenIdentifier(t *testing.T) {
|
||||
userdata := userInfo{
|
||||
Claims: map[string]any{"groups": []any{"admins"}},
|
||||
Groups: []string{"admins"},
|
||||
}
|
||||
idTokenClaims := map[string]any{
|
||||
"preferred_username": "alice",
|
||||
"sub": "user-123",
|
||||
}
|
||||
|
||||
mergeMissingOidcClaims(&userdata, idTokenClaims)
|
||||
|
||||
if userdata.Claims["preferred_username"] != "alice" {
|
||||
t.Fatalf("preferred_username = %v, want alice", userdata.Claims["preferred_username"])
|
||||
}
|
||||
if userdata.Claims["sub"] != "user-123" {
|
||||
t.Fatalf("sub = %v, want user-123", userdata.Claims["sub"])
|
||||
}
|
||||
if _, ok := userdata.Claims["groups"]; !ok {
|
||||
t.Fatal("UserInfo groups claim should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMissingOidcClaims_UserInfoWinsOnConflict(t *testing.T) {
|
||||
userdata := userInfo{
|
||||
Claims: map[string]any{"email": "from-userinfo@example.com"},
|
||||
}
|
||||
idTokenClaims := map[string]any{"email": "from-idtoken@example.com"}
|
||||
|
||||
mergeMissingOidcClaims(&userdata, idTokenClaims)
|
||||
|
||||
if userdata.Claims["email"] != "from-userinfo@example.com" {
|
||||
t.Fatalf("email = %v, want UserInfo value preserved", userdata.Claims["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOidcRedirectURLTrustProxyHeaders(t *testing.T) {
|
||||
settings.Config.Http.BaseURL = "/"
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,35 +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)
|
||||
drainRequestBody(r)
|
||||
return http.StatusConflict, acquireErr
|
||||
}
|
||||
logger.Debugf("%v", acquireErr)
|
||||
return http.StatusInternalServerError, acquireErr
|
||||
}
|
||||
|
||||
// On the first chunk, check for conflicts or handle override
|
||||
if offset == 0 {
|
||||
// Check for file/folder conflicts for chunked uploads
|
||||
@@ -820,7 +798,6 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
// If type mismatch (existing dir vs requesting file) and not overriding
|
||||
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("Type conflict detected in chunked: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
|
||||
drainRequestBody(r)
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
}
|
||||
@@ -830,7 +807,6 @@ func ResourcePostHandler(w http.ResponseWriter, r *http.Request, d *Context) (in
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
drainRequestBody(r)
|
||||
return http.StatusConflict, nil
|
||||
}
|
||||
// If overriding, delete existing thumbnails
|
||||
@@ -838,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)
|
||||
@@ -881,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
|
||||
@@ -936,10 +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)
|
||||
}
|
||||
if err = writeChunkUploadResponse(w, assembled, totalSize, assembled == totalSize); err != nil {
|
||||
logger.Debugf("could not write chunk upload response: %v", err)
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
@@ -954,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
|
||||
}
|
||||
|
||||
@@ -1115,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 {
|
||||
@@ -1169,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)
|
||||
@@ -1221,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{
|
||||
@@ -1233,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
|
||||
@@ -1257,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
|
||||
@@ -1278,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
|
||||
}
|
||||
}
|
||||
@@ -1299,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
|
||||
}
|
||||
|
||||
@@ -1313,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
|
||||
}
|
||||
|
||||
@@ -1325,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
|
||||
}
|
||||
|
||||
@@ -1337,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
|
||||
}
|
||||
|
||||
@@ -1359,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) {
|
||||
@@ -1370,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{
|
||||
@@ -1385,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"
|
||||
@@ -1401,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 {
|
||||
@@ -1421,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
|
||||
}
|
||||
}
|
||||
@@ -1446,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 {
|
||||
@@ -1540,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
|
||||
|
||||
@@ -1724,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,34 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
)
|
||||
|
||||
func TestCheckPermissionsMissingParamsStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts utils.FileOptions
|
||||
}{
|
||||
{name: "missing path", opts: utils.FileOptions{Source: "default"}},
|
||||
{name: "missing source", opts: utils.FileOptions{Path: "/"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, err := files.CheckPermissions(tt.opts, &users.User{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if status := ErrToStatus(err); status != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d err=%v, want %d", status, err, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,611 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
var uploadTestResolverMu sync.Mutex
|
||||
|
||||
func setupUploadHTTPTest(t *testing.T) (root string, user *users.User) {
|
||||
t.Helper()
|
||||
uploadTestResolverMu.Lock()
|
||||
|
||||
prevFilePerm := fileutils.PermFile
|
||||
prevDirPerm := fileutils.PermDir
|
||||
prevSourceMap := settings.Config.Server.SourceMap
|
||||
prevNameToSource := settings.Config.Server.NameToSource
|
||||
prevSourceResolver := users.GetSourceNameResolver()
|
||||
prevSourceConfig := users.GetSourceConfig()
|
||||
|
||||
t.Cleanup(func() {
|
||||
indexing.ClearTestIndices()
|
||||
fileutils.SetFsPermissions(uint32(prevFilePerm), uint32(prevDirPerm))
|
||||
settings.Config.Server.SourceMap = prevSourceMap
|
||||
settings.Config.Server.NameToSource = prevNameToSource
|
||||
users.SetSourceNameResolver(prevSourceResolver)
|
||||
users.SetSourceConfig(prevSourceConfig)
|
||||
uploadTestResolverMu.Unlock()
|
||||
})
|
||||
|
||||
fileutils.SetFsPermissions(0o644, 0o755)
|
||||
|
||||
root = t.TempDir()
|
||||
sourceName := "uploads"
|
||||
sourcePath := root
|
||||
|
||||
indexing.SetTestIndex(sourceName, sourcePath)
|
||||
idx := indexing.GetIndex(sourceName)
|
||||
if idx == nil {
|
||||
t.Fatal("expected test index")
|
||||
}
|
||||
idx.Config.ResolvedRules.IndexingDisabled = true
|
||||
|
||||
settings.Config.Server.SourceMap = map[string]*settings.Source{
|
||||
sourcePath: {Path: sourcePath, Name: sourceName},
|
||||
}
|
||||
settings.Config.Server.NameToSource = map[string]*settings.Source{
|
||||
sourceName: settings.Config.Server.SourceMap[sourcePath],
|
||||
}
|
||||
|
||||
users.SetSourceNameResolver(func(name string) (string, error) {
|
||||
if name == sourceName {
|
||||
return sourcePath, nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown source %q", name)
|
||||
})
|
||||
users.SetSourceConfig(&users.SourceConfigProvider{
|
||||
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
|
||||
if path == sourcePath {
|
||||
return users.SourceInfo{Path: sourcePath, Name: sourceName}, true
|
||||
}
|
||||
return users.SourceInfo{}, false
|
||||
},
|
||||
GetSourceByName: func(name string) (users.SourceInfo, bool) {
|
||||
if name == sourceName {
|
||||
return users.SourceInfo{Path: sourcePath, Name: sourceName}, true
|
||||
}
|
||||
return users.SourceInfo{}, false
|
||||
},
|
||||
})
|
||||
|
||||
perms := users.SourceFilePermissions{
|
||||
View: true, Download: true, Modify: true, Create: true, Delete: true,
|
||||
}
|
||||
user = &users.User{
|
||||
ID: 1,
|
||||
FrontendUser: users.FrontendUser{
|
||||
Username: "uploader",
|
||||
},
|
||||
BackendScopes: []users.BackendScope{
|
||||
{Path: sourcePath, Scope: "/", Permissions: perms},
|
||||
},
|
||||
BackendSourcePermissions: map[string]users.SourceFilePermissions{
|
||||
sourcePath: perms,
|
||||
},
|
||||
Version: users.SourcePermissionsMigrationVersion,
|
||||
}
|
||||
return root, user
|
||||
}
|
||||
|
||||
func postUpload(
|
||||
t *testing.T,
|
||||
user *users.User,
|
||||
path string,
|
||||
body io.Reader,
|
||||
contentLength int64,
|
||||
headers map[string]string,
|
||||
) (int, error) {
|
||||
t.Helper()
|
||||
url := "/api/resources?source=uploads&path=" + path
|
||||
req := httptest.NewRequest(http.MethodPost, url, body)
|
||||
if contentLength >= 0 {
|
||||
req.ContentLength = contentLength
|
||||
}
|
||||
if headers == nil {
|
||||
headers = map[string]string{}
|
||||
}
|
||||
if _, ok := headers["X-File-Upload-Session"]; !ok {
|
||||
headers["X-File-Upload-Session"] = "test-session"
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
return ResourcePostHandler(rec, req, &requestContext{User: user})
|
||||
}
|
||||
|
||||
func TestParseUploadTotalSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
total, ok, err := parseUploadTotalSize(req)
|
||||
if err != nil || ok || total != 0 {
|
||||
t.Fatalf("empty header: total=%d ok=%v err=%v", total, ok, err)
|
||||
}
|
||||
|
||||
req.Header.Set("X-File-Total-Size", "12345")
|
||||
total, ok, err = parseUploadTotalSize(req)
|
||||
if err != nil || !ok || total != 12345 {
|
||||
t.Fatalf("valid header: total=%d ok=%v err=%v", total, ok, err)
|
||||
}
|
||||
|
||||
req.Header.Set("X-File-Total-Size", "nope")
|
||||
_, _, err = parseUploadTotalSize(req)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReceivedBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateReceivedBytes(10, 10, true, 10); err != nil {
|
||||
t.Fatalf("matching sizes: %v", err)
|
||||
}
|
||||
if err := validateReceivedBytes(9, 10, true, -1); err == nil {
|
||||
t.Fatal("expected total size mismatch")
|
||||
}
|
||||
if err := validateReceivedBytes(9, 0, false, 10); err == nil {
|
||||
t.Fatal("expected content-length mismatch")
|
||||
}
|
||||
if err := validateReceivedBytes(10, 0, false, -1); err != nil {
|
||||
t.Fatalf("no expected sizes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsContentUpload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
if isContentUpload(req) {
|
||||
t.Fatal("empty create request should not be a content upload")
|
||||
}
|
||||
req.Header.Set("X-File-Total-Size", "0")
|
||||
if !isContentUpload(req) {
|
||||
t.Fatal("expected total-size header to mark content upload")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("X-File-Upload-Session", "abc")
|
||||
if !isContentUpload(req) {
|
||||
t.Fatal("expected upload session header to mark content upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChunkBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateChunkBounds(0, 10, 100); err != nil {
|
||||
t.Fatalf("valid bounds: %v", err)
|
||||
}
|
||||
if err := validateChunkBounds(-1, 10, 100); err == nil {
|
||||
t.Fatal("expected negative offset error")
|
||||
}
|
||||
if err := validateChunkBounds(0, 10, -1); err == nil {
|
||||
t.Fatal("expected negative total size error")
|
||||
}
|
||||
if err := validateChunkBounds(101, 1, 100); err == nil {
|
||||
t.Fatal("expected offset exceeds total error")
|
||||
}
|
||||
if err := validateChunkBounds(90, 20, 100); err == nil {
|
||||
t.Fatal("expected chunk exceeds remaining error")
|
||||
}
|
||||
if err := validateChunkBounds(math.MaxInt64, 1, math.MaxInt64); err == nil {
|
||||
t.Fatal("expected offset overflow error")
|
||||
}
|
||||
if err := validateAssembledSize(90, 20, 100); err == nil {
|
||||
t.Fatal("expected assembled size error")
|
||||
}
|
||||
if err := validateAssembledSize(0, -1, 100); err == nil {
|
||||
t.Fatal("expected negative chunk size error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUploadSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
if _, err := parseUploadSession(req); err == nil {
|
||||
t.Fatal("expected missing session error")
|
||||
}
|
||||
|
||||
req.Header.Set(uploadSessionHeader, "valid-Session_123")
|
||||
got, err := parseUploadSession(req)
|
||||
if err != nil || got != "valid-Session_123" {
|
||||
t.Fatalf("valid session: got=%q err=%v", got, err)
|
||||
}
|
||||
|
||||
req.Header.Set(uploadSessionHeader, "bad token!")
|
||||
if _, err = parseUploadSession(req); err == nil {
|
||||
t.Fatal("expected invalid character error")
|
||||
}
|
||||
|
||||
req.Header.Set(uploadSessionHeader, strings.Repeat("a", 129))
|
||||
if _, err = parseUploadSession(req); err == nil {
|
||||
t.Fatal("expected oversized session error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadTempPathStable(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := uploadTempPath("/data/photo.jpg", "session-a")
|
||||
b := uploadTempPath("/data/photo.jpg", "session-a")
|
||||
if a != b {
|
||||
t.Fatalf("expected stable temp path, got %q and %q", a, b)
|
||||
}
|
||||
if a == uploadTempPath("/data/photo.jpg", "session-b") {
|
||||
t.Fatal("expected different sessions to use different temp paths")
|
||||
}
|
||||
if filepath.Ext(a) != ".tmp" {
|
||||
t.Fatalf("expected .tmp suffix, got %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePostHandler_CreateEmptyFileWithoutSession(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
status, err := postUpload(t, user, "/empty.txt", bytes.NewReader(nil), 0, map[string]string{
|
||||
"X-File-Upload-Session": "",
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "empty.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty file, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePostHandler_NonChunkedMatchesTotalSize(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
body := []byte("hello-image-bytes")
|
||||
status, err := postUpload(t, user, "/photo.jpg", bytes.NewReader(body), int64(len(body)), map[string]string{
|
||||
"X-File-Total-Size": strconv.Itoa(len(body)),
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "photo.jpg"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, body) {
|
||||
t.Fatalf("file contents mismatch: got %q", got)
|
||||
}
|
||||
if matches, _ := filepath.Glob(filepath.Join(root, "*.uploading.tmp")); len(matches) != 0 {
|
||||
t.Fatalf("expected no leftover temp files, got %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePostHandler_NonChunkedTruncatedBody(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
body := []byte("partial")
|
||||
status, err := postUpload(t, user, "/photo.jpg", bytes.NewReader(body), int64(len(body)), map[string]string{
|
||||
"X-File-Total-Size": "100",
|
||||
})
|
||||
if status != http.StatusBadRequest || err == nil {
|
||||
t.Fatalf("status=%d err=%v, want 400", status, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "photo.jpg")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no final file, stat err=%v", err)
|
||||
}
|
||||
if matches, _ := filepath.Glob(filepath.Join(root, "*.uploading.tmp")); len(matches) != 0 {
|
||||
t.Fatalf("expected temp removed, got %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePostHandler_ChunkedTwoChunksComplete(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
part1 := []byte("aaaa")
|
||||
part2 := []byte("bbbb")
|
||||
total := len(part1) + len(part2)
|
||||
|
||||
status, err := postUpload(t, user, "/big.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
|
||||
"X-File-Chunk-Offset": "0",
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("chunk1 status=%d err=%v", status, err)
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(root, "big.bin")); !os.IsNotExist(err) {
|
||||
t.Fatal("final file should not exist after first chunk")
|
||||
}
|
||||
|
||||
status, err = postUpload(t, user, "/big.bin", bytes.NewReader(part2), int64(len(part2)), map[string]string{
|
||||
"X-File-Chunk-Offset": strconv.Itoa(len(part1)),
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("chunk2 status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "big.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := append(append([]byte{}, part1...), part2...)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("assembled mismatch: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePostHandler_ChunkedShortChunkContentLength(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
part1 := []byte("12345678")
|
||||
total := 16
|
||||
|
||||
status, err := postUpload(t, user, "/short.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
|
||||
"X-File-Chunk-Offset": "0",
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("chunk1 status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
short := []byte("xx")
|
||||
status, err = postUpload(t, user, "/short.bin", bytes.NewReader(short), 8, map[string]string{
|
||||
"X-File-Chunk-Offset": strconv.Itoa(len(part1)),
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusBadRequest || err == nil {
|
||||
t.Fatalf("status=%d err=%v, want 400", status, err)
|
||||
}
|
||||
|
||||
realPath := filepath.Join(root, "short.bin")
|
||||
tempPath := uploadTempPath(realPath, "test-session")
|
||||
info, err := os.Stat(tempPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected temp preserved: %v", err)
|
||||
}
|
||||
if info.Size() != int64(len(part1)) {
|
||||
t.Fatalf("temp size=%d, want %d", info.Size(), len(part1))
|
||||
}
|
||||
if _, err := os.Stat(realPath); !os.IsNotExist(err) {
|
||||
t.Fatal("final file should not exist after short chunk")
|
||||
}
|
||||
}
|
||||
|
||||
type errAfterReader struct {
|
||||
data []byte
|
||||
err error
|
||||
n int
|
||||
}
|
||||
|
||||
func (r *errAfterReader) Read(p []byte) (int, error) {
|
||||
if r.n >= len(r.data) {
|
||||
return 0, r.err
|
||||
}
|
||||
n := copy(p, r.data[r.n:])
|
||||
r.n += n
|
||||
if r.n >= len(r.data) {
|
||||
return n, r.err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func TestResourcePostHandler_ChunkedKeepsPartialOnBodyError(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
part1 := []byte("aaaaaaaa")
|
||||
total := 16
|
||||
|
||||
status, err := postUpload(t, user, "/resume.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
|
||||
"X-File-Chunk-Offset": "0",
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("chunk1 status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
failing := &errAfterReader{
|
||||
data: []byte("bbb"),
|
||||
err: errors.New("simulated disconnect"),
|
||||
}
|
||||
status, err = postUpload(t, user, "/resume.bin", failing, 8, map[string]string{
|
||||
"X-File-Chunk-Offset": strconv.Itoa(len(part1)),
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusInternalServerError || err == nil {
|
||||
t.Fatalf("status=%d err=%v, want 500", status, err)
|
||||
}
|
||||
|
||||
realPath := filepath.Join(root, "resume.bin")
|
||||
tempPath := uploadTempPath(realPath, "test-session")
|
||||
info, err := os.Stat(tempPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected temp preserved: %v", err)
|
||||
}
|
||||
if info.Size() != int64(len(part1)) {
|
||||
t.Fatalf("temp size=%d, want truncated to %d", info.Size(), len(part1))
|
||||
}
|
||||
|
||||
// Resume with a full second chunk.
|
||||
part2 := []byte("bbbbbbbb")
|
||||
status, err = postUpload(t, user, "/resume.bin", bytes.NewReader(part2), int64(len(part2)), map[string]string{
|
||||
"X-File-Chunk-Offset": strconv.Itoa(len(part1)),
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("resume status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := append(append([]byte{}, part1...), part2...)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("resumed contents mismatch: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePostHandler_ChunkOffsetExceedsTotal(t *testing.T) {
|
||||
_, user := setupUploadHTTPTest(t)
|
||||
body := []byte("data")
|
||||
status, err := postUpload(t, user, "/big.bin", bytes.NewReader(body), int64(len(body)), map[string]string{
|
||||
"X-File-Chunk-Offset": "200",
|
||||
"X-File-Total-Size": "100",
|
||||
})
|
||||
if status != http.StatusBadRequest || err == nil {
|
||||
t.Fatalf("status=%d err=%v, want 400", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePostHandler_ConflictingUploadSessions(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
part1 := []byte("aaaa")
|
||||
total := 8
|
||||
|
||||
status, err := postUpload(t, user, "/race.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
|
||||
"X-File-Upload-Session": "session-a",
|
||||
"X-File-Chunk-Offset": "0",
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("chunk1 status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
status, err = postUpload(t, user, "/race.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
|
||||
"X-File-Upload-Session": "session-b",
|
||||
"X-File-Chunk-Offset": "0",
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusConflict || err == nil {
|
||||
t.Fatalf("status=%d err=%v, want 409", status, err)
|
||||
}
|
||||
|
||||
// Non-chunked upload should also conflict with an active chunked session.
|
||||
status, err = postUpload(t, user, "/race.bin", bytes.NewReader([]byte("tiny")), 4, map[string]string{
|
||||
"X-File-Upload-Session": "session-c",
|
||||
"X-File-Total-Size": "4",
|
||||
})
|
||||
if status != http.StatusConflict || err == nil {
|
||||
t.Fatalf("non-chunked status=%d err=%v, want 409", status, err)
|
||||
}
|
||||
|
||||
// Original session can still resume.
|
||||
part2 := []byte("bbbb")
|
||||
status, err = postUpload(t, user, "/race.bin", bytes.NewReader(part2), int64(len(part2)), map[string]string{
|
||||
"X-File-Upload-Session": "session-a",
|
||||
"X-File-Chunk-Offset": strconv.Itoa(len(part1)),
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("resume status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "race.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := append(append([]byte{}, part1...), part2...)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("assembled mismatch: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePostHandler_ExpiredSessionAllowsNewUpload(t *testing.T) {
|
||||
root, user := setupUploadHTTPTest(t)
|
||||
part1 := []byte("aaaa")
|
||||
total := 8
|
||||
realPath := filepath.Join(root, "stale.bin")
|
||||
|
||||
status, err := postUpload(t, user, "/stale.bin", bytes.NewReader(part1), int64(len(part1)), map[string]string{
|
||||
"X-File-Upload-Session": "session-old",
|
||||
"X-File-Chunk-Offset": "0",
|
||||
"X-File-Total-Size": strconv.Itoa(total),
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("chunk1 status=%d err=%v", status, err)
|
||||
}
|
||||
|
||||
// Abandoned session still blocks until the lease expires.
|
||||
status, err = postUpload(t, user, "/stale.bin", bytes.NewReader([]byte("fullbody")), 8, map[string]string{
|
||||
"X-File-Upload-Session": "session-new",
|
||||
"X-File-Total-Size": "8",
|
||||
})
|
||||
if status != http.StatusConflict || err == nil {
|
||||
t.Fatalf("status=%d err=%v, want 409 while lease active", status, err)
|
||||
}
|
||||
|
||||
activeUploadSessions.expireForTest(realPath)
|
||||
|
||||
body := []byte("replaced!")
|
||||
status, err = postUpload(t, user, "/stale.bin", bytes.NewReader(body), int64(len(body)), map[string]string{
|
||||
"X-File-Upload-Session": "session-new",
|
||||
"X-File-Total-Size": strconv.Itoa(len(body)),
|
||||
})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("after expiry status=%d err=%v", status, err)
|
||||
}
|
||||
got, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, body) {
|
||||
t.Fatalf("got %q want %q", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePostHandler_PartialChunkResponse(t *testing.T) {
|
||||
_, user := setupUploadHTTPTest(t)
|
||||
part1 := []byte("aaaa")
|
||||
total := 8
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/resources?source=uploads&path=/partial.bin", bytes.NewReader(part1))
|
||||
req.ContentLength = int64(len(part1))
|
||||
req.Header.Set("X-File-Upload-Session", "session-partial")
|
||||
req.Header.Set("X-File-Chunk-Offset", "0")
|
||||
req.Header.Set("X-File-Total-Size", strconv.Itoa(total))
|
||||
|
||||
status, err := ResourcePostHandler(rec, req, &requestContext{User: user})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("chunk1 status=%d err=%v", status, err)
|
||||
}
|
||||
if rec.Header().Get("Connection") != "close" {
|
||||
t.Fatalf("expected Connection: close, got %q", rec.Header().Get("Connection"))
|
||||
}
|
||||
var payload chunkUploadResponse
|
||||
if decodeErr := json.NewDecoder(rec.Body).Decode(&payload); decodeErr != nil {
|
||||
t.Fatalf("decode response: %v", decodeErr)
|
||||
}
|
||||
if payload.Complete || payload.Offset != int64(len(part1)) || payload.Total != int64(total) {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
|
||||
part2 := []byte("bbbb")
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/resources?source=uploads&path=/partial.bin", bytes.NewReader(part2))
|
||||
req2.ContentLength = int64(len(part2))
|
||||
req2.Header.Set("X-File-Upload-Session", "session-partial")
|
||||
req2.Header.Set("X-File-Chunk-Offset", strconv.Itoa(len(part1)))
|
||||
req2.Header.Set("X-File-Total-Size", strconv.Itoa(total))
|
||||
|
||||
status, err = ResourcePostHandler(rec2, req2, &requestContext{User: user})
|
||||
if status != http.StatusOK || err != nil {
|
||||
t.Fatalf("chunk2 status=%d err=%v", status, err)
|
||||
}
|
||||
var finalPayload chunkUploadResponse
|
||||
if decodeErr := json.NewDecoder(rec2.Body).Decode(&finalPayload); decodeErr != nil {
|
||||
t.Fatalf("decode final response: %v", decodeErr)
|
||||
}
|
||||
if !finalPayload.Complete || finalPayload.Offset != int64(total) {
|
||||
t.Fatalf("unexpected final payload: %+v", finalPayload)
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func TestSessionCookie_HttpOnlyAndSecureOnHTTPS(t *testing.T) {
|
||||
prev := settings.Config.Http.TrustProxyHeaders
|
||||
settings.Config.Http.TrustProxyHeaders = true
|
||||
t.Cleanup(func() { settings.Config.Http.TrustProxyHeaders = prev })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Host = "example.com"
|
||||
|
||||
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
|
||||
if !c.HttpOnly {
|
||||
t.Fatal("expected HttpOnly session cookie")
|
||||
}
|
||||
if !c.Secure {
|
||||
t.Fatal("expected Secure session cookie over HTTPS")
|
||||
}
|
||||
if c.Name != sessionCookieName {
|
||||
t.Fatalf("cookie name: got %q", c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookie_NotSecureOnHTTP(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "http://localhost/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Host = "localhost"
|
||||
|
||||
c := sessionCookie(req, "token", time.Now().Add(time.Hour), 0, http.SameSiteStrictMode)
|
||||
if !c.HttpOnly {
|
||||
t.Fatal("expected HttpOnly session cookie")
|
||||
}
|
||||
if c.Secure {
|
||||
t.Fatal("did not expect Secure cookie on plain HTTP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpaContentSecurityPolicy_BlocksUntrustedScripts(t *testing.T) {
|
||||
prev := settings.Config.Integrations.OnlyOffice
|
||||
settings.Config.Integrations.OnlyOffice = settings.OnlyOffice{}
|
||||
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
|
||||
|
||||
csp := spaContentSecurityPolicy("testnonce")
|
||||
if csp != "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com" {
|
||||
t.Fatalf("unexpected CSP: %s", csp)
|
||||
}
|
||||
if strings.Contains(csp, "'unsafe-inline'") {
|
||||
t.Fatal("script-src must not allow unsafe-inline (srcdoc XSS)")
|
||||
}
|
||||
if strings.Contains(csp, "default-src") {
|
||||
t.Fatal("CSP should only restrict script-src to avoid breaking frames, blobs, and external assets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpaContentSecurityPolicy_IncludesOnlyOfficeOrigins(t *testing.T) {
|
||||
prev := settings.Config.Integrations.OnlyOffice
|
||||
t.Cleanup(func() { settings.Config.Integrations.OnlyOffice = prev })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
oo settings.OnlyOffice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "url only",
|
||||
oo: settings.OnlyOffice{Url: "http://localhost:9052"},
|
||||
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
|
||||
},
|
||||
{
|
||||
name: "internal url only",
|
||||
oo: settings.OnlyOffice{InternalUrl: "http://onlyoffice-internal:80"},
|
||||
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://onlyoffice-internal:80",
|
||||
},
|
||||
{
|
||||
name: "both urls",
|
||||
oo: settings.OnlyOffice{
|
||||
Url: "https://office.example.com",
|
||||
InternalUrl: "http://onlyoffice-internal",
|
||||
},
|
||||
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com https://office.example.com http://onlyoffice-internal",
|
||||
},
|
||||
{
|
||||
name: "duplicate origins",
|
||||
oo: settings.OnlyOffice{
|
||||
Url: "http://localhost:9052",
|
||||
InternalUrl: "http://localhost:9052",
|
||||
},
|
||||
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com http://localhost:9052",
|
||||
},
|
||||
{
|
||||
name: "invalid urls ignored",
|
||||
oo: settings.OnlyOffice{
|
||||
Url: "not-a-url",
|
||||
InternalUrl: "://bad",
|
||||
},
|
||||
want: "script-src 'self' 'nonce-testnonce' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
settings.Config.Integrations.OnlyOffice = tt.oo
|
||||
if got := spaContentSecurityPolicy("testnonce"); got != tt.want {
|
||||
t.Fatalf("unexpected CSP:\ngot: %s\nwant: %s", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
@@ -43,13 +42,7 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
|
||||
return fmt.Errorf("error reloading template: %w", err)
|
||||
}
|
||||
}
|
||||
nonce, err := utils.CSPNonceFromData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Inherited by srcdoc preview frames: nonce'd SPA scripts run; untrusted preview scripts do not.
|
||||
w.Header().Set("Content-Security-Policy", spaContentSecurityPolicy(nonce))
|
||||
// Set headers
|
||||
w.Header().Set("Cache-Control", "no-cache, private, max-age=0")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("X-Accel-Expires", "0")
|
||||
@@ -58,51 +51,6 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
|
||||
return templates.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
// spaContentSecurityPolicy restricts only scripts. srcdoc preview frames inherit
|
||||
// this header, blocking inline scripts without limiting frames, images, or API calls.
|
||||
func spaContentSecurityPolicy(nonce string) string {
|
||||
policy := fmt.Sprintf(
|
||||
"script-src 'self' 'nonce-%s' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
|
||||
nonce,
|
||||
)
|
||||
for _, origin := range onlyOfficeScriptSrcOrigins() {
|
||||
policy += " " + origin
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func onlyOfficeScriptSrcOrigins() []string {
|
||||
oo := settings.Config.Integrations.OnlyOffice
|
||||
candidates := []string{
|
||||
cspOriginFromURL(oo.Url),
|
||||
cspOriginFromURL(oo.InternalUrl),
|
||||
}
|
||||
var origins []string
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
for _, origin := range candidates {
|
||||
if origin == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[origin]; ok {
|
||||
continue
|
||||
}
|
||||
seen[origin] = struct{}{}
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
func cspOriginFromURL(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
userSelectedTheme := ""
|
||||
@@ -132,7 +80,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
externalLinks = newExternalLinks
|
||||
}
|
||||
|
||||
defaultThemeColor := settings.DefaultBackgroundColor()
|
||||
defaultThemeColor := "#455a64"
|
||||
staticURL := settings.Config.Http.BaseURL + "public/static"
|
||||
description := settings.Config.Frontend.Description
|
||||
title := settings.Config.Frontend.Name
|
||||
@@ -222,7 +170,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"title": title,
|
||||
"customCSS": template.CSS(settings.Config.Frontend.Styling.CustomCSSRaw),
|
||||
"userSelectedTheme": template.CSS(userSelectedTheme),
|
||||
"defaultDarkMode": settings.DefaultDarkMode(),
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"staticURL": staticURL,
|
||||
@@ -276,20 +223,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"loginButtonText": settings.Config.Frontend.LoginButtonText,
|
||||
"passkeyAvailable": settings.Config.Auth.Methods.PasskeyAuth.Enabled,
|
||||
"passkeyLoginButtonText": settings.Config.Auth.Methods.PasskeyAuth.LoginButtonText,
|
||||
"disablePWAInstall": settings.Config.Frontend.DisablePWAInstall,
|
||||
}
|
||||
|
||||
cspNonce, err := utils.CSPNonce()
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("csp nonce: %w", err)
|
||||
}
|
||||
globalVars, ok := data["globalVars"].(map[string]interface{})
|
||||
if !ok {
|
||||
return http.StatusInternalServerError, errors.New("unable to load global variables")
|
||||
}
|
||||
globalVars["cspNonce"] = cspNonce
|
||||
data["cspNonce"] = cspNonce
|
||||
|
||||
// Marshal each variable to JSON strings for direct template usage
|
||||
globalVarsJSON, err := json.Marshal(data["globalVars"])
|
||||
if err != nil {
|
||||
@@ -389,7 +324,6 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "icons/favicon-32x32.png",
|
||||
"icons/pwa-icon-192.png", "icons/pwa-icon-256.png", "icons/pwa-icon-512.png",
|
||||
"icons/pwa-icon-maskable-192.png", "icons/pwa-icon-maskable-512.png",
|
||||
"icons/apple-touch-icon.png", "icons/mstile-256x256.png":
|
||||
// Files are generated as PWAIconsCacheDir()/basename (URL uses icons/ prefix only for routing)
|
||||
rel := strings.TrimPrefix(path, "icons/")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// uploadTempPath returns the temporary path used while assembling an upload for realPath.
|
||||
// sessionID isolates concurrent uploads targeting the same destination.
|
||||
func uploadTempPath(realPath, sessionID string) string {
|
||||
hasher := md5.New()
|
||||
hasher.Write([]byte(realPath))
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write([]byte(sessionID))
|
||||
return fmt.Sprintf("%s.%s.uploading.tmp", realPath, hex.EncodeToString(hasher.Sum(nil)))
|
||||
}
|
||||
|
||||
// parseUploadTotalSize reads optional X-File-Total-Size.
|
||||
// ok is false when the header is absent.
|
||||
func parseUploadTotalSize(r *http.Request) (total int64, ok bool, err error) {
|
||||
s := r.Header.Get("X-File-Total-Size")
|
||||
if s == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
total, err = strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("invalid total size: %v", err)
|
||||
}
|
||||
if total < 0 {
|
||||
return 0, false, fmt.Errorf("invalid total size: %d", total)
|
||||
}
|
||||
return total, true, nil
|
||||
}
|
||||
|
||||
// validateChunkBounds rejects invalid offsets and sizes before writing a chunk.
|
||||
// contentLength is the request Content-Length when known (>0).
|
||||
func validateChunkBounds(offset, contentLength, totalSize int64) error {
|
||||
if offset < 0 {
|
||||
return fmt.Errorf("invalid chunk offset: %d", offset)
|
||||
}
|
||||
if totalSize < 0 {
|
||||
return fmt.Errorf("invalid total size: %d", totalSize)
|
||||
}
|
||||
if offset > totalSize {
|
||||
return fmt.Errorf("chunk offset exceeds total size: offset %d > %d", offset, totalSize)
|
||||
}
|
||||
remaining := totalSize - offset
|
||||
if contentLength > 0 {
|
||||
if contentLength > remaining {
|
||||
return fmt.Errorf("chunk exceeds total size: offset %d + %d > %d", offset, contentLength, totalSize)
|
||||
}
|
||||
if offset > math.MaxInt64-contentLength {
|
||||
return fmt.Errorf("chunk offset overflow")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAssembledSize checks written chunk bytes against the declared total without overflow.
|
||||
func validateAssembledSize(offset, chunkSize, totalSize int64) error {
|
||||
if chunkSize < 0 {
|
||||
return fmt.Errorf("invalid chunk size: %d", chunkSize)
|
||||
}
|
||||
if err := validateChunkBounds(offset, chunkSize, totalSize); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateReceivedBytes compares bytes written to optional declared totals.
|
||||
// expectedTotal is checked only when hasExpected is true.
|
||||
// contentLength is checked when greater than 0 (known Content-Length).
|
||||
func validateReceivedBytes(written, expectedTotal int64, hasExpected bool, contentLength int64) error {
|
||||
if hasExpected && written != expectedTotal {
|
||||
return fmt.Errorf("upload incomplete: expected %d bytes, received %d", expectedTotal, written)
|
||||
}
|
||||
if contentLength > 0 && written != contentLength {
|
||||
return fmt.Errorf("upload incomplete: expected %d bytes, received %d", contentLength, written)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isContentUpload reports whether the request is uploading file bytes (as opposed to
|
||||
// creating an empty file via POST with no upload metadata).
|
||||
func isContentUpload(r *http.Request) bool {
|
||||
if r.Header.Get("X-File-Chunk-Offset") != "" {
|
||||
return true
|
||||
}
|
||||
if r.Header.Get(uploadSessionHeader) != "" {
|
||||
return true
|
||||
}
|
||||
_, ok, err := parseUploadTotalSize(r)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
func drainRequestBody(r *http.Request) {
|
||||
if r.Body == nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
|
||||
type chunkUploadResponse struct {
|
||||
Offset int64 `json:"offset"`
|
||||
Total int64 `json:"total"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
func writeChunkUploadResponse(w http.ResponseWriter, offset, total int64, complete bool) error {
|
||||
w.Header().Set("Connection", "close")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
return json.NewEncoder(w).Encode(chunkUploadResponse{
|
||||
Offset: offset,
|
||||
Total: total,
|
||||
Complete: complete,
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -11,17 +13,15 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/adapters/fs/files"
|
||||
|
||||
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
|
||||
)
|
||||
|
||||
type UserRequest struct {
|
||||
@@ -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>")
|
||||
}
|
||||
@@ -273,25 +276,6 @@ func validatePatchWhich(which []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateNonAdminSelfPatchWhich rejects self-service PATCH attempts that touch privileged fields.
|
||||
// Non-admins may only update NonAdminEditable profile fields, plus password (with X-Password) and otpEnabled.
|
||||
func validateNonAdminSelfPatchWhich(which []string) error {
|
||||
allowed := nonAdminEditableFieldNameSet()
|
||||
for _, w := range which {
|
||||
f := utils.CapitalizeFirst(strings.TrimSpace(w))
|
||||
if f == "" {
|
||||
return fmt.Errorf("which must not contain empty field names")
|
||||
}
|
||||
if strings.EqualFold(f, "Password") || strings.EqualFold(f, "OtpEnabled") {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[f]; !ok {
|
||||
return fmt.Errorf("cannot update privileged user fields without admin permissions")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// userPutOnlyNonAdminEditableFields reports whether req.Which lists exclusively NonAdminEditable fields,
|
||||
// excluding Password.
|
||||
func userPutOnlyNonAdminEditableFields(which []string) bool {
|
||||
@@ -392,11 +376,6 @@ func userPatchHandler(w http.ResponseWriter, r *http.Request, d *Context) (int,
|
||||
if !d.User.Permissions.Admin && targetUsername != d.User.Username {
|
||||
return http.StatusForbidden, nil
|
||||
}
|
||||
if !d.User.Permissions.Admin && targetUsername == d.User.Username {
|
||||
if err = validateNonAdminSelfPatchWhich(req.Which); err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
}
|
||||
|
||||
uValue, err2 := state.GetUserByUsername(targetUsername)
|
||||
if err2 == errors.ErrNotExist {
|
||||
|
||||
@@ -53,51 +53,3 @@ func TestValidatePatchWhich(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateNonAdminSelfPatchWhich(t *testing.T) {
|
||||
t.Run("allows profile fields", func(t *testing.T) {
|
||||
for _, which := range [][]string{
|
||||
{"locale"},
|
||||
{"darkMode", "viewMode"},
|
||||
{"preview"},
|
||||
{"sorting"},
|
||||
} {
|
||||
if err := validateNonAdminSelfPatchWhich(which); err != nil {
|
||||
t.Fatalf("unexpected error for which=%v: %v", which, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allows password and otpEnabled", func(t *testing.T) {
|
||||
for _, which := range [][]string{
|
||||
{"password"},
|
||||
{"otpEnabled"},
|
||||
{"locale", "password"},
|
||||
} {
|
||||
if err := validateNonAdminSelfPatchWhich(which); err != nil {
|
||||
t.Fatalf("unexpected error for which=%v: %v", which, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects privileged fields", func(t *testing.T) {
|
||||
for _, which := range [][]string{
|
||||
{"scopes"},
|
||||
{"permissions"},
|
||||
{"sourcePermissions"},
|
||||
{"loginMethod"},
|
||||
{"lockPassword"},
|
||||
{"disableSettings"},
|
||||
} {
|
||||
if err := validateNonAdminSelfPatchWhich(which); err == nil {
|
||||
t.Fatalf("expected error for which=%v", which)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects mixed privileged and profile fields", func(t *testing.T) {
|
||||
if err := validateNonAdminSelfPatchWhich([]string{"locale", "scopes"}); err == nil {
|
||||
t.Fatal("expected error when which mixes profile and privileged fields")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user