mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-14 05:01:59 -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,9 +5,6 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_playwright:
|
||||
name: Test Playwright
|
||||
@@ -33,44 +30,3 @@ jobs:
|
||||
context: .
|
||||
file: ./_docker/Dockerfile.playwright-general
|
||||
push: false
|
||||
|
||||
create_beta_release:
|
||||
needs: [test_playwright]
|
||||
if: startsWith(github.event.head_commit.message, 'Beta/v')
|
||||
name: Create Beta Release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
fetch-depth: 0
|
||||
- name: Extract version from commit message
|
||||
id: extract_version
|
||||
shell: bash
|
||||
run: |
|
||||
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
|
||||
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
|
||||
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
|
||||
- name: Tag release commit on main
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
TAG="${{ steps.extract_version.outputs.tag_name }}"
|
||||
git tag -f "$TAG" "${{ github.sha }}"
|
||||
git push origin "refs/tags/$TAG" --force
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_version.outputs.tag_name }}
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
overwrite_existing: true
|
||||
name: ${{ steps.extract_version.outputs.tag_name }}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
@@ -2,73 +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.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}' \
|
||||
|
||||
@@ -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
|
||||
+96
-91
@@ -3,7 +3,7 @@ module github.com/gtsteffaniak/filebrowser/backend
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kong v1.16.1
|
||||
github.com/alecthomas/kong v1.16.0
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
@@ -19,48 +19,47 @@ require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-ffmpeg v0.5.2
|
||||
github.com/gtsteffaniak/go-ffmpeg v0.5.1
|
||||
github.com/gtsteffaniak/go-logger v1.1.0
|
||||
github.com/kovidgoyal/imaging v1.8.23
|
||||
github.com/mattn/go-sqlite3 v1.14.50
|
||||
github.com/mattn/go-sqlite3 v1.14.49
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/mod v0.40.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/mod v0.38.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.49.0
|
||||
golang.org/x/tools v0.48.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.57.0
|
||||
modernc.org/sqlite v1.55.0
|
||||
)
|
||||
|
||||
require (
|
||||
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.6 // indirect
|
||||
codeberg.org/chavacava/garif v0.2.1 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.3 // indirect
|
||||
codeberg.org/chavacava/garif v0.2.0 // indirect
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
|
||||
dev.gaijin.team/go/exhaustruct/v5 v5.0.3 // indirect
|
||||
dev.gaijin.team/go/golib v0.8.1 // indirect
|
||||
dev.gaijin.team/go/golib v0.6.0 // indirect
|
||||
github.com/4meepo/tagalign v1.4.3 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.8 // indirect
|
||||
github.com/Abirdcfly/dupword v0.1.7 // indirect
|
||||
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
|
||||
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
|
||||
github.com/Antonboom/errname v1.1.2 // indirect
|
||||
github.com/Antonboom/nilnil v1.1.2 // indirect
|
||||
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
|
||||
github.com/Antonboom/errname v1.1.1 // indirect
|
||||
github.com/Antonboom/nilnil v1.1.1 // indirect
|
||||
github.com/Antonboom/testifylint v1.6.4 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
|
||||
github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect
|
||||
github.com/Djarvur/go-err113 v0.1.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.5.0 // indirect
|
||||
@@ -74,41 +73,43 @@ require (
|
||||
github.com/alfatraining/structtag v1.0.0 // indirect
|
||||
github.com/alingse/asasalint v0.0.11 // indirect
|
||||
github.com/alingse/nilnesserr v0.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
|
||||
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bep/godartsass/v2 v2.5.0 // indirect
|
||||
github.com/bep/golibsass v1.2.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.25.0 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.5 // indirect
|
||||
github.com/bkielbasa/cyclop v1.2.3 // indirect
|
||||
github.com/blizzy78/varnamelen v0.8.0 // indirect
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.9.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.8.0 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/breml/bidichk v0.3.3 // indirect
|
||||
github.com/breml/errchkjson v0.4.1 // indirect
|
||||
github.com/butuzov/ireturn v0.4.1 // indirect
|
||||
github.com/butuzov/mirror v1.3.3 // indirect
|
||||
github.com/butuzov/mirror v1.3.0 // indirect
|
||||
github.com/catenacyber/perfsprint v0.10.1 // indirect
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charithe/durationcheck v0.0.11 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260812204455-68fa937c71be // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/ckaznocha/intrange v0.3.1 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/curioswitch/go-reassign v0.3.0 // indirect
|
||||
github.com/daixiang0/gci v0.14.0 // indirect
|
||||
github.com/dave/dst v0.27.4 // indirect
|
||||
github.com/daixiang0/gci v0.13.7 // indirect
|
||||
github.com/dave/dst v0.27.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/denis-tingaikin/go-header v0.5.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -116,26 +117,26 @@ require (
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.8 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
|
||||
github.com/firefart/nonamedreturns v1.0.6 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/fzipp/gocyclo v0.6.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.21 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.20 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-critic/go-critic v0.14.4 // indirect
|
||||
github.com/go-critic/go-critic v0.14.3 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/spec v0.22.9 // indirect
|
||||
github.com/go-openapi/swag/conv v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/pools v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/pools v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-toolsmith/astcast v1.1.0 // indirect
|
||||
@@ -146,26 +147,26 @@ require (
|
||||
github.com/go-toolsmith/strparse v1.1.0 // indirect
|
||||
github.com/go-toolsmith/typep v1.1.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.3.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/gohugoio/hashstructure v1.0.0 // indirect
|
||||
github.com/gohugoio/hugo v0.165.0 // indirect
|
||||
github.com/gohugoio/hashstructure v0.6.0 // indirect
|
||||
github.com/gohugoio/hugo v0.164.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/geo v0.0.0-20260713102120-857a528af641 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golangci/asciicheck v0.5.0 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.13.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.12.2 // indirect
|
||||
github.com/golangci/golines v0.15.0 // indirect
|
||||
github.com/golangci/misspell v0.8.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
github.com/golangci/revgrep v0.8.0 // indirect
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260801150524-d2031e3af752 // indirect
|
||||
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
|
||||
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
@@ -177,14 +178,15 @@ require (
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hexops/gotextdiff v1.0.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jgautheron/goconst v1.11.0 // indirect
|
||||
github.com/jgautheron/goconst v1.10.0 // indirect
|
||||
github.com/jjti/go-spancheck v0.6.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.20.0 // indirect
|
||||
github.com/kisielk/errcheck v1.10.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
@@ -192,90 +194,93 @@ require (
|
||||
github.com/kunwardeep/paralleltest v1.0.15 // indirect
|
||||
github.com/lasiar/canonicalheader v1.1.2 // indirect
|
||||
github.com/ldez/exptostd v0.4.5 // indirect
|
||||
github.com/ldez/gomoddirectives v0.9.0 // indirect
|
||||
github.com/ldez/gomoddirectives v0.8.0 // indirect
|
||||
github.com/ldez/grignotin v0.10.1 // indirect
|
||||
github.com/ldez/structtags v0.6.1 // indirect
|
||||
github.com/ldez/tagliatelle v0.7.2 // indirect
|
||||
github.com/ldez/usetesting v0.5.0 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/leonklingele/grouper v1.1.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/macabu/inamedparam v0.2.0 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
|
||||
github.com/manuelarte/funcorder v0.6.0 // indirect
|
||||
github.com/maratori/testableexamples v1.0.1 // indirect
|
||||
github.com/maratori/testpackage v1.1.2 // indirect
|
||||
github.com/matoous/godox v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.28 // indirect
|
||||
github.com/mgechev/revive v1.16.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/mgechev/revive v1.15.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
|
||||
github.com/moricho/tparallel v0.3.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nakabonne/nestif v0.3.1 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nishanths/exhaustive v0.12.0 // indirect
|
||||
github.com/nishanths/predeclared v0.2.2 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_golang v1.24.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.12.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.32.1 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
|
||||
github.com/quasilyte/gogrep v0.5.0 // indirect
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
|
||||
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
|
||||
github.com/raeperd/recvcheck v0.3.0 // indirect
|
||||
github.com/raeperd/recvcheck v0.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/ryancurrah/gomodguard v1.4.1 // indirect
|
||||
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
|
||||
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.28.0 // indirect
|
||||
github.com/sirupsen/logrus v1.10.1 // indirect
|
||||
github.com/securego/gosec/v2 v2.26.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sivchari/containedctx v1.0.3 // indirect
|
||||
github.com/sonatard/noctx v0.5.1 // indirect
|
||||
github.com/sourcegraph/go-diff v0.8.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/spf13/viper v1.12.0 // indirect
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.4.1 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.16 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.12 // indirect
|
||||
github.com/tetafro/godot v1.5.6 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
|
||||
github.com/timonwong/loggercheck v0.11.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
|
||||
github.com/ultraware/funlen v0.2.0 // indirect
|
||||
github.com/ultraware/whitespace v0.2.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.7 // indirect
|
||||
github.com/urfave/cli/v2 v2.3.0 // indirect
|
||||
github.com/uudashr/gocognit v1.2.1 // indirect
|
||||
github.com/uudashr/iface v1.5.0 // indirect
|
||||
github.com/uudashr/iface v1.4.2 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xen0n/gosmopolitan v1.3.0 // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yagipy/maintidx v1.0.0 // indirect
|
||||
github.com/yeya24/promlinter v0.3.0 // indirect
|
||||
github.com/ykadowak/zerologlint v0.1.5 // indirect
|
||||
@@ -283,25 +288,25 @@ require (
|
||||
go-simpler.org/musttag v0.14.0 // indirect
|
||||
go-simpler.org/sloglint v0.12.0 // indirect
|
||||
go.augendre.info/arangolint v0.4.0 // indirect
|
||||
go.augendre.info/fatcontext v0.10.0 // indirect
|
||||
go.augendre.info/fatcontext v0.9.0 // indirect
|
||||
go.etcd.io/bbolt v1.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.28.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260820142414-ca536658362e // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
honnef.co/go/tools v0.8.1 // indirect
|
||||
modernc.org/libc v1.75.4 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
mvdan.cc/gofumpt v0.11.0 // indirect
|
||||
mvdan.cc/unparam v0.0.0-20260820064413-a201bf8e0538 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
|
||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
|
||||
+693
-247
File diff suppressed because it is too large.
Load diff
@@ -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 {
|
||||
|
||||
@@ -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,63 +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) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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,15 +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 {
|
||||
return fmt.Sprintf(
|
||||
"script-src 'self' 'nonce-%s' https://cdn.jsdelivr.net https://www.google.com https://www.gstatic.com",
|
||||
nonce,
|
||||
)
|
||||
}
|
||||
|
||||
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
userSelectedTheme := ""
|
||||
@@ -96,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
|
||||
@@ -211,8 +195,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"minSearchLength": settings.Config.Server.MinSearchLength,
|
||||
"disableExternal": settings.Config.Frontend.DisableDefaultLinks,
|
||||
"darkMode": settings.Config.UserDefaults.UI.DarkMode,
|
||||
"lightBackground": settings.Config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": settings.Config.Frontend.Styling.DarkBackground,
|
||||
"baseURL": settings.Config.Http.BaseURL,
|
||||
"version": versionString,
|
||||
"commitSHA": commitSHAString,
|
||||
@@ -241,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 {
|
||||
@@ -354,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 {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
jwt.DecodeStrict = true
|
||||
cmd.StartFilebrowser()
|
||||
}
|
||||
@@ -428,7 +428,6 @@ type PathContext struct {
|
||||
type FileInfoRequest struct {
|
||||
IndexPath string
|
||||
FollowSymlinks bool
|
||||
BoundIndexPath string // index prefix for symlink containment (e.g. user scope)
|
||||
ShowHidden bool
|
||||
HideFileExt string // Hide files based on their extension
|
||||
Expand bool // get child items for directories
|
||||
@@ -436,64 +435,10 @@ type FileInfoRequest struct {
|
||||
SkipExtendedAttrs bool // whether to skip extended attributes
|
||||
}
|
||||
|
||||
// filesystemBound resolves the filesystem path for an index prefix under this source.
|
||||
func (idx *Index) filesystemBound(boundIndexPath string) (string, error) {
|
||||
bound := boundIndexPath
|
||||
if bound == "" {
|
||||
bound = "/"
|
||||
}
|
||||
fsPath := idx.Path
|
||||
if bound != "/" {
|
||||
fsPath = utils.JoinUnderSourceRoot(idx.Path, bound)
|
||||
}
|
||||
abs, err := filepath.Abs(fsPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(abs)
|
||||
}
|
||||
|
||||
// ensureResolvedContained rejects resolved filesystem paths outside the source or scope prefix.
|
||||
func (idx *Index) ensureResolvedContained(realPath, boundIndexPath string) error {
|
||||
sourceRoot, err := idx.filesystemBound("/")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := iteminfo.PathWithinRoot(sourceRoot, realPath); err != nil {
|
||||
return errors.ErrPathEscapesScope
|
||||
}
|
||||
bound := boundIndexPath
|
||||
if bound == "" {
|
||||
bound = "/"
|
||||
}
|
||||
if bound != "/" {
|
||||
scopeRoot, err := idx.filesystemBound(bound)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := iteminfo.PathWithinRoot(scopeRoot, realPath); err != nil {
|
||||
return errors.ErrPathEscapesScope
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSymlinksContained resolves symlinks and ensures the target stays within bounds.
|
||||
func (idx *Index) resolveSymlinksContained(fsPath, boundIndexPath string) (string, error) {
|
||||
resolved, err := filepath.EvalSymlinks(fsPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := idx.ensureResolvedContained(resolved, boundIndexPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// resolvePathContext resolves all path characteristics in a SINGLE stat call
|
||||
// This eliminates duplicate stat/lstat calls and redundant isHidden/isSymlink checks
|
||||
func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool, boundIndexPath string) (*PathContext, error) {
|
||||
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
|
||||
func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*PathContext, error) {
|
||||
realPath := filepath.Join(idx.Path, indexPath)
|
||||
|
||||
// ONE filesystem stat call
|
||||
fileInfo, err := os.Lstat(realPath)
|
||||
@@ -505,7 +450,7 @@ func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool, boun
|
||||
|
||||
// Resolve symlink if requested
|
||||
if isSymlink && followSymlinks {
|
||||
realPath, err = idx.resolveSymlinksContained(realPath, boundIndexPath)
|
||||
realPath, err = filepath.EvalSymlinks(realPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -561,7 +506,7 @@ func (idx *Index) evaluateIndexRules(ctx *PathContext, isRoutineScan bool) (isVi
|
||||
// User permission checking happens in the API layer (FileInfoFaster)
|
||||
func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
|
||||
// 1. Resolve path context (single stat call)
|
||||
ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks, req.BoundIndexPath)
|
||||
ctx, err := idx.resolvePathContext(req.IndexPath, req.FollowSymlinks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -649,7 +594,7 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
|
||||
if !strings.HasSuffix(indexPath, "/") {
|
||||
indexPath = indexPath + "/"
|
||||
}
|
||||
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
|
||||
realPath := filepath.Join(idx.Path, indexPath)
|
||||
|
||||
// Open the directory
|
||||
dir, err := os.Open(realPath)
|
||||
@@ -687,10 +632,10 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
|
||||
// GetFsInfoCore is the consolidated implementation for both GetFsInfo and GetFsInfoViewableOnly
|
||||
func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileInfo, error) {
|
||||
// Handle symlinks if not following them
|
||||
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
|
||||
realPath := filepath.Join(idx.Path, indexPath)
|
||||
if opts.FollowSymlinks {
|
||||
var err error
|
||||
realPath, err = idx.resolveSymlinksContained(realPath, "/")
|
||||
realPath, err = filepath.EvalSymlinks(realPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1046,28 +991,10 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, indexPath strin
|
||||
}
|
||||
|
||||
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
|
||||
return idx.getRealPathInternal("/", false, relativePath...)
|
||||
}
|
||||
|
||||
// GetRealPathScoped resolves an index path and rejects symlink targets outside the source and bound prefix.
|
||||
func (idx *Index) GetRealPathScoped(boundIndexPath string, relativePath ...string) (string, bool, error) {
|
||||
return idx.getRealPathInternal(boundIndexPath, true, relativePath...)
|
||||
}
|
||||
|
||||
func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool, relativePath ...string) (string, bool, error) {
|
||||
parts := make([]string, 0, len(relativePath)+1)
|
||||
parts = append(parts, idx.Path)
|
||||
for _, p := range relativePath {
|
||||
parts = append(parts, utils.CleanIndexPathSegment(p))
|
||||
}
|
||||
joinedPath := filepath.Join(parts...)
|
||||
bound := boundIndexPath
|
||||
if enforceScope && bound == "" {
|
||||
bound = "/"
|
||||
}
|
||||
cacheKey := fmt.Sprintf("realpath|scoped=%t|bound=%q|path=%q", enforceScope, bound, joinedPath)
|
||||
isDir, _ := IsDirCache.Get(cacheKey + ":isdir")
|
||||
cached, ok := RealPathCache.Get(cacheKey)
|
||||
combined := append([]string{idx.Path}, relativePath...)
|
||||
joinedPath := filepath.Join(combined...)
|
||||
isDir, _ := IsDirCache.Get(joinedPath + ":isdir")
|
||||
cached, ok := RealPathCache.Get(joinedPath)
|
||||
if ok && cached != "" {
|
||||
return cached, isDir, nil
|
||||
}
|
||||
@@ -1076,19 +1003,11 @@ func (idx *Index) getRealPathInternal(boundIndexPath string, enforceScope bool,
|
||||
return absolutePath, false, fmt.Errorf("could not get real path: %v, %s", joinedPath, err)
|
||||
}
|
||||
realPath, isDir, err := iteminfo.ResolveSymlinks(absolutePath)
|
||||
if err != nil {
|
||||
return realPath, isDir, err
|
||||
if err == nil {
|
||||
RealPathCache.Set(joinedPath, realPath)
|
||||
IsDirCache.Set(joinedPath+":isdir", isDir)
|
||||
}
|
||||
if enforceScope {
|
||||
if err := idx.ensureResolvedContained(realPath, boundIndexPath); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
} else if err := idx.ensureResolvedContained(realPath, "/"); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
RealPathCache.Set(cacheKey, realPath)
|
||||
IsDirCache.Set(cacheKey+":isdir", isDir)
|
||||
return realPath, isDir, nil
|
||||
return realPath, isDir, err
|
||||
}
|
||||
|
||||
func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
|
||||
@@ -1115,7 +1034,7 @@ func (idx *Index) RefreshDirectory(indexPath string, recursive bool) error {
|
||||
indexPath = indexPath + "/"
|
||||
}
|
||||
|
||||
realPath := utils.JoinUnderSourceRoot(idx.Path, indexPath)
|
||||
realPath := filepath.Join(idx.Path, indexPath)
|
||||
|
||||
// Check if directory exists
|
||||
dirInfo, err := os.Stat(realPath)
|
||||
|
||||
@@ -105,35 +105,6 @@ func (info *FileInfo) SortItems() {
|
||||
})
|
||||
}
|
||||
|
||||
// PathWithinRoot reports whether path is equal to or under root after resolving symlinks.
|
||||
func PathWithinRoot(root, path string) error {
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid root path: %w", err)
|
||||
}
|
||||
rootReal, err := filepath.EvalSymlinks(rootAbs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve root path: %w", err)
|
||||
}
|
||||
pathAbs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
pathReal, err := filepath.EvalSymlinks(pathAbs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve path: %w", err)
|
||||
}
|
||||
rel, err := filepath.Rel(rootReal, pathReal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("path outside root: %w", err)
|
||||
}
|
||||
sep := string(filepath.Separator)
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+sep) {
|
||||
return fmt.Errorf("path escapes root")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveSymlinks resolves symlinks in the given path and returns
|
||||
// Uses Go's filepath.EvalSymlinks which properly detects circular symlinks.
|
||||
func ResolveSymlinks(path string) (string, bool, error) {
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
liberrors "github.com/gtsteffaniak/filebrowser/backend/internal/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
|
||||
)
|
||||
|
||||
func setupSymlinkEscapeFixture(t *testing.T) (sourceRoot, userScope string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
sourceRoot = filepath.Join(root, "srv")
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outsideSecret := filepath.Join(root, "secret-outside.txt")
|
||||
if err := os.WriteFile(outsideSecret, []byte("ROOT_DB_PASSWORD=supersecret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideSecret, filepath.Join(userDir, "secret_link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sibling := filepath.Join(root, "outside-sibling.txt")
|
||||
if err := os.WriteFile(sibling, []byte("outside sibling"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(sibling, filepath.Join(userDir, "sibling_link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inside := filepath.Join(userDir, "allowed.txt")
|
||||
if err := os.WriteFile(inside, []byte("allowed"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sourceRoot, "/user1"
|
||||
}
|
||||
|
||||
func symlinkTestIndex(sourceRoot string) *Index {
|
||||
return &Index{Source: settings.Source{Path: sourceRoot}}
|
||||
}
|
||||
|
||||
func TestPathWithinRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
inside := filepath.Join(root, "inner", "file.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(inside, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(root), "outside.txt")
|
||||
if err := os.WriteFile(outside, []byte("y"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := iteminfo.PathWithinRoot(root, inside); err != nil {
|
||||
t.Fatalf("expected inside path to be allowed: %v", err)
|
||||
}
|
||||
if err := iteminfo.PathWithinRoot(root, outside); err == nil {
|
||||
t.Fatal("expected outside path to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPath_RejectsEscapeOutsideSource(t *testing.T) {
|
||||
sourceRoot, _ := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
_, _, err := idx.GetRealPath("/user1/secret_link")
|
||||
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope, got %v", err)
|
||||
}
|
||||
_, _, err = idx.GetRealPath("/user1/sibling_link")
|
||||
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope for sibling link, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathScoped_RejectsEscapeOutsideUserScope(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
// Under source but outside /user1: place secret inside source root
|
||||
insideSource := filepath.Join(sourceRoot, "secret-in-source.txt")
|
||||
if err := os.WriteFile(insideSource, []byte("in source"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.Symlink(insideSource, filepath.Join(userDir, "in_source_link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := idx.GetRealPathScoped(userScope, "/user1/in_source_link")
|
||||
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope for in-source escape, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathScoped_AllowsInScopeTarget(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.Symlink("allowed.txt", filepath.Join(userDir, "good_link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
realPath, _, err := idx.GetRealPathScoped(userScope, "/user1/good_link")
|
||||
if err != nil {
|
||||
t.Fatalf("expected in-scope symlink to resolve: %v", err)
|
||||
}
|
||||
want := filepath.Join(userDir, "allowed.txt")
|
||||
realPath, err = filepath.EvalSymlinks(realPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err = filepath.EvalSymlinks(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if realPath != want {
|
||||
t.Fatalf("got real path %q, want %q", realPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRealPathScoped_CacheKeyDoesNotCollideWithUnscoped(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
insideSource := filepath.Join(sourceRoot, "secret-in-source.txt")
|
||||
if err := os.WriteFile(insideSource, []byte("in source"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userDir := filepath.Join(sourceRoot, "user1")
|
||||
if err := os.WriteFile(filepath.Join(userDir, "foo"), []byte("legitimate"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Legacy scoped cache keys used joinedPath + ":bound:" + bound, which could collide
|
||||
// with an unscoped joinedPath when ":bound:" appeared in a path component.
|
||||
legacyKey := filepath.Join(sourceRoot, "user1", "foo") + ":bound:" + "/user1"
|
||||
RealPathCache.Set(legacyKey, insideSource)
|
||||
IsDirCache.Set(legacyKey+":isdir", false)
|
||||
|
||||
realPath, _, err := idx.GetRealPathScoped(userScope, "/user1/foo")
|
||||
if err != nil {
|
||||
t.Fatalf("scoped in-scope file should resolve: %v", err)
|
||||
}
|
||||
want := filepath.Join(userDir, "foo")
|
||||
want, err = filepath.EvalSymlinks(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realPath, err = filepath.EvalSymlinks(realPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if realPath != want {
|
||||
t.Fatalf("scoped lookup returned %q, want in-scope file %q", realPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileInfo_RejectsSymlinkEscapeWithBound(t *testing.T) {
|
||||
sourceRoot, userScope := setupSymlinkEscapeFixture(t)
|
||||
idx := symlinkTestIndex(sourceRoot)
|
||||
|
||||
_, err := idx.GetFileInfo(FileInfoRequest{
|
||||
IndexPath: "/user1/secret_link",
|
||||
FollowSymlinks: true,
|
||||
BoundIndexPath: userScope,
|
||||
})
|
||||
if !errors.Is(err, liberrors.ErrPathEscapesScope) {
|
||||
t.Fatalf("expected ErrPathEscapesScope from GetFileInfo, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ package indexing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -66,7 +66,7 @@ func (idx *Index) handleFile(file os.FileInfo, indexPath string, realFilePath st
|
||||
|
||||
// Use provided realFilePath if available, otherwise construct it
|
||||
if realFilePath == "" {
|
||||
realFilePath = utils.JoinUnderSourceRoot(idx.Path, indexPath)
|
||||
realFilePath = filepath.Join(idx.Path, indexPath)
|
||||
}
|
||||
|
||||
sys := file.Sys()
|
||||
|
||||
@@ -149,10 +149,6 @@ func ValidateLdapAuth() error {
|
||||
if ldapCfg.UserFilter == "" {
|
||||
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
|
||||
}
|
||||
if ldapCfg.GroupsClaim == "" {
|
||||
ldapCfg.GroupsClaim = defaultLdapGroupsClaim
|
||||
}
|
||||
applyAuthCommonDefaults(&ldapCfg.AuthCommon)
|
||||
if err := verifyLdapConnection(); err != nil {
|
||||
logger.Fatalf("LDAP connection check failed: %v", err)
|
||||
}
|
||||
@@ -162,7 +158,9 @@ func ValidateLdapAuth() error {
|
||||
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
|
||||
func validateOidcAuth() error {
|
||||
oidcCfg := &Config.Auth.Methods.OidcAuth // Use a pointer to modify the original config
|
||||
applyAuthCommonDefaults(&oidcCfg.AuthCommon)
|
||||
if oidcCfg.GroupsClaim == "" {
|
||||
oidcCfg.GroupsClaim = "groups"
|
||||
}
|
||||
if oidcCfg.UserIdentifier == "" {
|
||||
oidcCfg.UserIdentifier = "preferred_username"
|
||||
}
|
||||
@@ -291,7 +289,9 @@ func ValidateJwtAuth() error {
|
||||
if jwtCfg.Algorithm == "" {
|
||||
jwtCfg.Algorithm = "HS256"
|
||||
}
|
||||
applyAuthCommonDefaults(&jwtCfg.AuthCommon)
|
||||
if jwtCfg.GroupsClaim == "" {
|
||||
jwtCfg.GroupsClaim = "groups"
|
||||
}
|
||||
if jwtCfg.UserIdentifier == "" {
|
||||
jwtCfg.UserIdentifier = "sub"
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package settings
|
||||
|
||||
const (
|
||||
defaultGroupsClaim = "groups"
|
||||
defaultLdapGroupsClaim = "memberOf"
|
||||
)
|
||||
|
||||
// ResolveGroupsClaim returns the configured groups claim or the shared default when empty.
|
||||
func ResolveGroupsClaim(claim string) string {
|
||||
if claim == "" {
|
||||
return defaultGroupsClaim
|
||||
}
|
||||
return claim
|
||||
}
|
||||
|
||||
// ResolveLdapGroupsClaim returns the configured LDAP groups attribute or memberOf when empty.
|
||||
func ResolveLdapGroupsClaim(claim string) string {
|
||||
if claim == "" {
|
||||
return defaultLdapGroupsClaim
|
||||
}
|
||||
return claim
|
||||
}
|
||||
|
||||
// applyAuthCommonDefaults sets shared defaults for external auth methods that support group claims.
|
||||
func applyAuthCommonDefaults(c *AuthCommon) {
|
||||
c.GroupsClaim = ResolveGroupsClaim(c.GroupsClaim)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package settings
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApplyAuthCommonDefaults(t *testing.T) {
|
||||
empty := AuthCommon{}
|
||||
applyAuthCommonDefaults(&empty)
|
||||
if empty.GroupsClaim != "groups" {
|
||||
t.Fatalf("default groupsClaim = %q, want groups", empty.GroupsClaim)
|
||||
}
|
||||
|
||||
custom := AuthCommon{GroupsClaim: "memberOf"}
|
||||
applyAuthCommonDefaults(&custom)
|
||||
if custom.GroupsClaim != "memberOf" {
|
||||
t.Fatalf("custom groupsClaim = %q, want memberOf", custom.GroupsClaim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGroupsClaim(t *testing.T) {
|
||||
if got := ResolveGroupsClaim(""); got != "groups" {
|
||||
t.Fatalf("ResolveGroupsClaim(\"\") = %q, want groups", got)
|
||||
}
|
||||
if got := ResolveGroupsClaim("memberOf"); got != "memberOf" {
|
||||
t.Fatalf("ResolveGroupsClaim(memberOf) = %q, want memberOf", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLdapGroupsClaim(t *testing.T) {
|
||||
if got := ResolveLdapGroupsClaim(""); got != "memberOf" {
|
||||
t.Fatalf("ResolveLdapGroupsClaim(\"\") = %q, want memberOf", got)
|
||||
}
|
||||
if got := ResolveLdapGroupsClaim("groups"); got != "groups" {
|
||||
t.Fatalf("ResolveLdapGroupsClaim(groups) = %q, want groups", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLdapAuthCommonDefaultsUsesMemberOf(t *testing.T) {
|
||||
ldapCfg := LdapConfig{AuthCommon: AuthCommon{}}
|
||||
if ldapCfg.GroupsClaim == "" {
|
||||
ldapCfg.GroupsClaim = defaultLdapGroupsClaim
|
||||
}
|
||||
applyAuthCommonDefaults(&ldapCfg.AuthCommon)
|
||||
if ldapCfg.GroupsClaim != "memberOf" {
|
||||
t.Fatalf("ldap groupsClaim = %q, want memberOf", ldapCfg.GroupsClaim)
|
||||
}
|
||||
}
|
||||
@@ -31,16 +31,6 @@ const (
|
||||
)
|
||||
|
||||
func Initialize(configFile string) {
|
||||
initializeConfig(configFile, false)
|
||||
}
|
||||
|
||||
// InitializeCLI loads config for CLI subcommands without server-only setup (ffmpeg, frontend, etc.).
|
||||
func InitializeCLI(configFile string) {
|
||||
initializeConfig(configFile, true)
|
||||
}
|
||||
|
||||
func initializeConfig(configFile string, cliMode bool) {
|
||||
Env.IsCLIMode = cliMode
|
||||
err := loadConfigWithDefaults(configFile, false)
|
||||
if err != nil {
|
||||
logger.Error("unable to load config, waiting 5 seconds before exiting...")
|
||||
@@ -67,9 +57,6 @@ func initializeConfig(configFile string, cliMode bool) {
|
||||
setupSources(false)
|
||||
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
|
||||
setupUrls()
|
||||
if cliMode {
|
||||
return
|
||||
}
|
||||
warnHttpProxyConfig()
|
||||
setupFrontend(false)
|
||||
setupMedia(false)
|
||||
@@ -528,7 +515,6 @@ func setupAuth(generate bool) {
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password")
|
||||
}
|
||||
if Config.Auth.Methods.ProxyAuth.Enabled {
|
||||
applyAuthCommonDefaults(&Config.Auth.Methods.ProxyAuth.AuthCommon)
|
||||
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
|
||||
}
|
||||
if Config.Auth.Methods.NoAuth {
|
||||
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user